mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-09 02:39:49 -10:00
Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2095945f40 | ||
|
|
2955cdcf9c | ||
|
|
5a48beab07 | ||
|
|
dfa610beb0 | ||
|
|
e86134ce83 | ||
|
|
4bc08a1084 | ||
|
|
cb2f249db5 | ||
|
|
2dd57d095f | ||
|
|
5ab5c8c677 | ||
|
|
14aa612038 | ||
|
|
ab5f71b442 | ||
|
|
c5124b809d | ||
|
|
a206ac0ca5 | ||
|
|
b12fabd472 | ||
|
|
e77ee39047 | ||
|
|
1a008fd17e | ||
|
|
d2603facdf | ||
|
|
89c7ae7d4c | ||
|
|
89b178f9aa | ||
|
|
c5c5930a75 | ||
|
|
40be4e4c17 | ||
|
|
502cc348ba | ||
|
|
eb72b78abb | ||
|
|
542584abb8 | ||
|
|
0ccc1e687d | ||
|
|
92b527ab0e | ||
|
|
0e81c6aeb1 | ||
|
|
f8e15a7980 | ||
|
|
d17f6755f3 | ||
|
|
f1c10f4d84 | ||
|
|
54d35d8a67 | ||
|
|
b6b09eb968 | ||
|
|
36fae164a8 | ||
|
|
8c21a1e088 | ||
|
|
ecba8aff68 | ||
|
|
c90d3a7a8e | ||
|
|
115d789091 | ||
|
|
fb92a82101 | ||
|
|
7829bee55c |
No files matched your search
Generated
+4
@@ -4273,6 +4273,7 @@ dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"libc",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4305,6 +4306,8 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"libc",
|
||||
"memchr",
|
||||
"parking_lot",
|
||||
@@ -4452,6 +4455,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
|
||||
@@ -109,11 +109,11 @@ fn strip_comments(text: &str) -> String {
|
||||
/// Quoted string literals in `s`.
|
||||
fn literals(s: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '"' || c == '\'' {
|
||||
out.push(chars.by_ref().take_while(|&n| n != c).collect());
|
||||
}
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find(['"', '\'']) {
|
||||
let Some((value, used)) = literal(&rest[start..]) else { break };
|
||||
out.push(value);
|
||||
rest = &rest[start + used..];
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -565,11 +565,12 @@ fn is_dependency_configuration(name: &str) -> bool {
|
||||
/// `(group, artifact, version)` of the external dependencies a build script declares:
|
||||
/// `implementation 'g:a:v'`, `api("g:a:v:classifier@jar")`, and the map notation
|
||||
/// `implementation group: 'g', name: 'a', version: 'v'` (Groovy) / `group = "g", …` (Kotlin).
|
||||
/// Versions with interpolations, ranges and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
/// Versions written as `${gsonVersion}` are filled in from `properties` (`ext` values, see
|
||||
/// [`script_properties`]); ranges, unknown names and catalog aliases (`libs.x`) are skipped.
|
||||
pub fn script_dependencies(text: &str, properties: &BTreeMap<String, String>) -> Vec<(String, String, String)> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for line in text.lines().flat_map(|l| l.split(['{', ';'])) {
|
||||
for line in text.lines().flat_map(statements) {
|
||||
let line = line.trim_start();
|
||||
let name: String = line.chars().take_while(|c| c.is_ascii_alphanumeric() || *c == '_').collect();
|
||||
if name.is_empty() || !is_dependency_configuration(&name) {
|
||||
@@ -591,6 +592,7 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
}),
|
||||
};
|
||||
if let Some((g, a, v)) = coordinate {
|
||||
let v = interpolate(&v, properties);
|
||||
let plain = |s: &str| {
|
||||
!s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '+'))
|
||||
};
|
||||
@@ -602,11 +604,354 @@ pub fn script_dependencies(text: &str) -> Vec<(String, String, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// `line` split at `{` and `;` outside quoted strings, so a `${version}` stays in one piece.
|
||||
fn statements(line: &str) -> Vec<&str> {
|
||||
let mut out = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut index = 0;
|
||||
while index < line.len() {
|
||||
let c = line[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&line[index..]).map_or(line.len() - index, |(_, used)| used);
|
||||
continue;
|
||||
}
|
||||
if matches!(c, '{' | '}' | ';' | '\n') {
|
||||
out.push(&line[start..index]);
|
||||
start = index + c.len_utf8();
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
out.push(&line[start..]);
|
||||
out
|
||||
}
|
||||
|
||||
/// `apply from: 'x.gradle'` (Groovy) and `apply(from = "x.gradle.kts")` (Kotlin DSL)
|
||||
/// targets, as written in the script.
|
||||
pub fn applied_scripts(text: &str) -> Vec<String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = Vec::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if word != "apply" || !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let mut rest = text[index + "apply".len()..].trim_start();
|
||||
if let Some(inner) = rest.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
let Some(rest) = rest.strip_prefix("from") else { continue };
|
||||
let Some(rest) = rest.trim_start().strip_prefix(':').or_else(|| rest.trim_start().strip_prefix('=')) else {
|
||||
continue;
|
||||
};
|
||||
let mut rest = rest.trim_start();
|
||||
if let Some(inner) = rest.strip_prefix("file") {
|
||||
let inner = inner.trim_start();
|
||||
if let Some(inner) = inner.strip_prefix('(') {
|
||||
rest = inner.trim_start();
|
||||
}
|
||||
}
|
||||
if let Some((path, _)) = literal(rest) {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Literal extra properties declared by Groovy's `ext` or Kotlin's `extra`.
|
||||
pub fn script_properties(text: &str) -> BTreeMap<String, String> {
|
||||
let text = strip_comments(text);
|
||||
let mut out = BTreeMap::new();
|
||||
for (index, word) in code_words(&text) {
|
||||
if !starts_word(&text, index) {
|
||||
continue;
|
||||
}
|
||||
let rest = text[index + word.len()..].trim_start();
|
||||
if word == "val" || word == "var" {
|
||||
let (name, rest) = identifier(rest);
|
||||
let Some(rest) = rest.trim_start().strip_prefix("by").map(str::trim_start) else { continue };
|
||||
let Some(rest) = rest.strip_prefix("extra").map(str::trim_start) else { continue };
|
||||
if let Some(rest) = rest.strip_prefix('(')
|
||||
&& let Some((value, used)) = literal(rest.trim_start())
|
||||
&& rest.trim_start()[used..].trim_start().starts_with(')')
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if word != "ext" && word != "extra" {
|
||||
continue;
|
||||
}
|
||||
if let Some(block) = rest.strip_prefix('{') {
|
||||
if let Some(end) = matching_brace(block) {
|
||||
out.extend(assignments(&block[..end]));
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('.') {
|
||||
let (name, rest) = identifier(rest);
|
||||
if let Some(value) = assigned_literal(rest)
|
||||
&& !name.is_empty()
|
||||
{
|
||||
out.insert(name.to_owned(), value);
|
||||
}
|
||||
} else if let Some(rest) = rest.strip_prefix('[')
|
||||
&& let Some((name, used)) = literal(rest.trim_start())
|
||||
&& let Some(rest) = rest.trim_start()[used..].trim_start().strip_prefix(']')
|
||||
&& let Some(value) = assigned_literal(rest)
|
||||
{
|
||||
out.insert(name, value);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub(crate) fn file_properties(build_dir: &Path) -> BTreeMap<String, String> {
|
||||
let text = std::fs::read_to_string(build_dir.join("gradle.properties")).unwrap_or_default();
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
if line.starts_with(['#', '!']) {
|
||||
return None;
|
||||
}
|
||||
let separator = line.find(|c: char| c == '=' || c == ':' || c.is_whitespace())?;
|
||||
let (key, rest) = line.split_at(separator);
|
||||
let value = rest.trim_start().trim_start_matches(['=', ':']).trim_start();
|
||||
(!key.is_empty()).then(|| (key.to_owned(), value.to_owned()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The build script of a project and, recursively, the scripts it applies with `apply from`,
|
||||
/// read from disk: absolute path and text. Targets that do not exist are skipped.
|
||||
pub fn scripts_of(root: &Path, project_dir: &Path, build_dir: &Path, build_file: &Path) -> Vec<(PathBuf, String)> {
|
||||
const MAX_SCRIPTS: usize = 256;
|
||||
const MAX_DEPTH: usize = 32;
|
||||
let mut out = Vec::new();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let first = normalize(&root.join(build_file));
|
||||
let Ok(identity) = dunce::canonicalize(&first) else { return out };
|
||||
seen.insert(identity);
|
||||
let mut stack = vec![(first, 0)];
|
||||
while let Some((path, depth)) = stack.pop() {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else { continue };
|
||||
if depth < MAX_DEPTH {
|
||||
let mut targets = Vec::new();
|
||||
for spec in applied_scripts(&text) {
|
||||
if seen.len() >= MAX_SCRIPTS {
|
||||
break;
|
||||
}
|
||||
if let Some(target) = resolve_script(&spec, &path, project_dir, build_dir)
|
||||
&& let Ok(identity) = dunce::canonicalize(&target)
|
||||
&& seen.insert(identity)
|
||||
{
|
||||
targets.push((target, depth + 1));
|
||||
}
|
||||
}
|
||||
stack.extend(targets.into_iter().rev());
|
||||
}
|
||||
out.push((path, text));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Relative targets are project-scoped, with a lenient fallback beside the applying script.
|
||||
fn resolve_script(spec: &str, script: &Path, project_dir: &Path, build_dir: &Path) -> Option<PathBuf> {
|
||||
let spec = spec.trim();
|
||||
if spec.contains("://") {
|
||||
return None;
|
||||
}
|
||||
let properties = BTreeMap::from([
|
||||
("rootDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("rootProject.projectDir".into(), build_dir.to_string_lossy().into_owned()),
|
||||
("projectDir".into(), project_dir.to_string_lossy().into_owned()),
|
||||
]);
|
||||
let spec = interpolate(spec, &properties);
|
||||
if spec.contains('$') {
|
||||
return None;
|
||||
}
|
||||
let path = Path::new(&spec);
|
||||
if path.is_absolute() {
|
||||
return path.is_file().then(|| normalize(path));
|
||||
}
|
||||
let project_path = normalize(&project_dir.join(path));
|
||||
if project_path.is_file() {
|
||||
return Some(project_path);
|
||||
}
|
||||
let path = normalize(&script.parent()?.join(path));
|
||||
path.is_file().then_some(path)
|
||||
}
|
||||
|
||||
pub(crate) fn project_build_dir(root: &Path, model: &GradleModel, project: &GradleProject) -> PathBuf {
|
||||
let dir = project.build.as_ref().and_then(|build| model.projects.iter().find(|p| &p.path == build));
|
||||
root.join(dir.map_or(&model.dir, |p| &p.dir))
|
||||
}
|
||||
|
||||
/// `2.8.9` for `${gsonVersion}` / `$gsonVersion` / `${ext.gsonVersion}`; unknown names and
|
||||
/// expressions are left as written, so ranges and catalogs stay skipped.
|
||||
fn interpolate(value: &str, properties: &BTreeMap<String, String>) -> String {
|
||||
if !value.contains('$') {
|
||||
return value.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(value.len());
|
||||
let mut chars = value.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '$' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
let braced = chars.peek() == Some(&'{');
|
||||
if braced {
|
||||
chars.next();
|
||||
}
|
||||
let mut name = String::new();
|
||||
if braced {
|
||||
for n in chars.by_ref() {
|
||||
if n == '}' {
|
||||
break;
|
||||
}
|
||||
name.push(n);
|
||||
}
|
||||
} else {
|
||||
while let Some(&n) = chars.peek() {
|
||||
if n.is_alphanumeric() || n == '_' || n == '.' {
|
||||
name.push(n);
|
||||
chars.next();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let key = name.strip_prefix("ext.").or_else(|| name.strip_prefix("extra.")).unwrap_or(&name);
|
||||
let bracket = name.strip_prefix("ext[").or_else(|| name.strip_prefix("extra[")).and_then(|rest| {
|
||||
let (key, used) = literal(rest.trim_start())?;
|
||||
(rest.trim_start()[used..].trim() == "]").then_some(key)
|
||||
});
|
||||
match properties.get(bracket.as_deref().unwrap_or(key)) {
|
||||
Some(v) if !name.is_empty() => out.push_str(v),
|
||||
_ => {
|
||||
out.push('$');
|
||||
if braced {
|
||||
out.push('{');
|
||||
}
|
||||
out.push_str(&name);
|
||||
if braced {
|
||||
out.push('}');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether `index` starts a whole word in `text` (not `reapply`, `my.apply`).
|
||||
fn starts_word(text: &str, index: usize) -> bool {
|
||||
text[..index].chars().next_back().is_none_or(|c| !(c.is_alphanumeric() || c == '_' || c == '.'))
|
||||
}
|
||||
|
||||
/// Identifiers outside quoted strings, with their byte positions.
|
||||
fn code_words(text: &str) -> Vec<(usize, &str)> {
|
||||
let mut out = Vec::new();
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next().unwrap();
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..]).map_or(text.len() - index, |(_, used)| used);
|
||||
} else if c.is_alphabetic() || c == '_' {
|
||||
let (word, _) = identifier(&text[index..]);
|
||||
out.push((index, word));
|
||||
index += word.len();
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn identifier(text: &str) -> (&str, &str) {
|
||||
let end = text.find(|c: char| !(c.is_alphanumeric() || c == '_')).unwrap_or(text.len());
|
||||
text.split_at(end)
|
||||
}
|
||||
|
||||
fn assigned_literal(text: &str) -> Option<String> {
|
||||
let rest = text.trim_start().strip_prefix('=')?.trim_start();
|
||||
let (value, used) = literal(rest)?;
|
||||
// Do not turn a computed value (`'1.' + suffix`) into a concrete version.
|
||||
let tail = rest[used..].trim_start_matches([' ', '\t', '\r']);
|
||||
(tail.is_empty() || tail.starts_with(['\n', ';', '}'])).then_some(value)
|
||||
}
|
||||
|
||||
/// The quoted string at the start of `s`, and how many bytes it spans.
|
||||
fn literal(s: &str) -> Option<(String, usize)> {
|
||||
let quote = s.chars().next().filter(|c| *c == '"' || *c == '\'')?;
|
||||
let rest = &s[quote.len_utf8()..];
|
||||
let mut index = 0;
|
||||
while index < rest.len() {
|
||||
let c = rest[index..].chars().next()?;
|
||||
if c == quote {
|
||||
return Some((rest[..index].to_owned(), quote.len_utf8() + index + c.len_utf8()));
|
||||
}
|
||||
if c == '\\' {
|
||||
index += c.len_utf8();
|
||||
index += rest[index..].chars().next()?.len_utf8();
|
||||
} else if quote == '"' && rest[index..].starts_with("${") {
|
||||
index += 2;
|
||||
index += matching_brace(&rest[index..])? + 1;
|
||||
} else {
|
||||
index += c.len_utf8();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Byte index of the `}` closing a `{` that was already consumed, ignoring quoted text.
|
||||
fn matching_brace(text: &str) -> Option<usize> {
|
||||
let mut depth = 1usize;
|
||||
let mut index = 0;
|
||||
while index < text.len() {
|
||||
let c = text[index..].chars().next()?;
|
||||
if c == '"' || c == '\'' {
|
||||
index += literal(&text[index..])?.1;
|
||||
continue;
|
||||
}
|
||||
if c == '{' {
|
||||
depth += 1;
|
||||
} else if c == '}' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
index += c.len_utf8();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// `name = 'value'` pairs in `text`.
|
||||
fn assignments(text: &str) -> Vec<(String, String)> {
|
||||
statements(text)
|
||||
.into_iter()
|
||||
.filter_map(|statement| {
|
||||
let (name, rest) = identifier(statement.trim_start());
|
||||
let value = assigned_literal(rest)?;
|
||||
(!name.is_empty()).then(|| (name.to_owned(), value))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every settings and build script of the model (for change detection).
|
||||
pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
let dir = root.join(&model.dir);
|
||||
let mut out: Vec<PathBuf> = SETTINGS_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
|
||||
out.extend(model.projects.iter().filter_map(|p| p.build_file.as_ref().map(|f| root.join(f))));
|
||||
// Scripts a build script applies are build files too: editing one can change the model.
|
||||
for project in &model.projects {
|
||||
let Some(file) = project.build_file.as_deref() else { continue };
|
||||
let build_dir = project_build_dir(root, model, project);
|
||||
let properties = build_dir.join("gradle.properties");
|
||||
if properties.is_file() {
|
||||
out.push(properties);
|
||||
}
|
||||
let applied = scripts_of(root, &root.join(&project.dir), &build_dir, Path::new(file));
|
||||
out.extend(applied.into_iter().skip(1).map(|(path, _)| path));
|
||||
}
|
||||
// The settings of each nested build (its root project's folder).
|
||||
for p in model.projects.iter().filter(|p| p.build.as_deref() == Some(p.path.as_str())) {
|
||||
let nested = root.join(&p.dir);
|
||||
@@ -624,6 +969,8 @@ pub fn build_files(root: &Path, model: &GradleModel) -> Vec<PathBuf> {
|
||||
catalogs.sort();
|
||||
out.extend(catalogs);
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
@@ -808,7 +1155,7 @@ dependencies {
|
||||
classpath 'not:a:library'
|
||||
}
|
||||
"#;
|
||||
let deps = script_dependencies(script);
|
||||
let deps = script_dependencies(script, &BTreeMap::new());
|
||||
let names: Vec<String> = deps.iter().map(|(g, a, v)| format!("{g}:{a}:{v}")).collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
@@ -822,6 +1169,291 @@ dependencies {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_read_groovy_and_kotlin_forms() {
|
||||
let script = r#"
|
||||
apply plugin: 'java'
|
||||
apply from: 'gradle/dependencies.gradle'
|
||||
apply from: file('gradle/extra.gradle')
|
||||
apply(from = "gradle/kotlin.gradle.kts")
|
||||
apply(from = file("gradle/kotlin-file.gradle.kts"))
|
||||
// apply from: 'commented.gradle'
|
||||
reapply from: 'not-a-call.gradle'
|
||||
"#;
|
||||
assert_eq!(
|
||||
applied_scripts(script),
|
||||
[
|
||||
"gradle/dependencies.gradle",
|
||||
"gradle/extra.gradle",
|
||||
"gradle/kotlin.gradle.kts",
|
||||
"gradle/kotlin-file.gradle.kts"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ext_values_fill_in_dependency_versions() {
|
||||
let script = r#"
|
||||
ext {
|
||||
gsonVersion = '2.8.9'
|
||||
junit = "4.13.2"
|
||||
}
|
||||
ext.logback = '1.4.14'
|
||||
dependencies {
|
||||
implementation "com.google.code.gson:gson:${gsonVersion}"
|
||||
testImplementation "junit:junit:$junit"
|
||||
implementation "ch.qos.logback:logback-classic:${ext.logback}"
|
||||
implementation 'org.plain:literal:1.0'
|
||||
implementation "org.x:unknown:${missing}"
|
||||
}
|
||||
"#;
|
||||
let names: Vec<String> = script_dependencies(script, &script_properties(script))
|
||||
.into_iter()
|
||||
.map(|(g, a, v)| format!("{g}:{a}:{v}"))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
names,
|
||||
[
|
||||
"com.google.code.gson:gson:2.8.9",
|
||||
"junit:junit:4.13.2",
|
||||
"ch.qos.logback:logback-classic:1.4.14",
|
||||
"org.plain:literal:1.0",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_properties_support_brackets_and_kotlin_delegates() {
|
||||
for declaration in [
|
||||
"ext.x = '1.2'",
|
||||
"ext { x = '1.2' }",
|
||||
"ext[\"x\"] = '1.2'",
|
||||
"extra[\"x\"] = \"1.2\"",
|
||||
"val x by extra(\"1.2\")",
|
||||
] {
|
||||
let script = format!("{declaration}\ndependencies {{ implementation \"g:a:${{x}}\" }}");
|
||||
assert_eq!(
|
||||
script_dependencies(&script, &script_properties(&script)),
|
||||
[("g".into(), "a".into(), "1.2".into())],
|
||||
"{declaration}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsers_ignore_apply_and_ext_inside_strings() {
|
||||
let script = r#"
|
||||
println "apply from: 'fake.gradle'"
|
||||
println "ext.x = 'bogus'"
|
||||
ext.real = '1'
|
||||
"#;
|
||||
assert!(applied_scripts(script).is_empty());
|
||||
assert_eq!(script_properties(script), BTreeMap::from([("real".into(), "1".into())]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_statements_preserve_interpolation_and_escaped_quotes() {
|
||||
let script = r#"dependencies { implementation "g:a:${v}"; api(group = "g", name = "b", version = "${v}-RC1") }; dependencies { runtimeOnly 'g:c:2' }"#;
|
||||
let properties = BTreeMap::from([("v".into(), "1.2".into())]);
|
||||
assert_eq!(
|
||||
script_dependencies(script, &properties),
|
||||
[
|
||||
("g".into(), "a".into(), "1.2".into()),
|
||||
("g".into(), "b".into(), "1.2-RC1".into()),
|
||||
("g".into(), "c".into(), "2".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
statements(r#"println "escaped\"; {still quoted}"; implementation 'g:a:1'"#),
|
||||
[r#"println "escaped\"; {still quoted}""#, " implementation 'g:a:1'"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_versions_skip_unresolved_names_and_expressions() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:missing:${unknown}"
|
||||
implementation "g:expression:${1 + 2}"
|
||||
implementation "g:cyclic:${cycle}"
|
||||
implementation 'g:range:[1,2)'
|
||||
implementation 'g:dynamic:1.+'
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("cycle".into(), "${cycle}".into())]);
|
||||
assert!(script_dependencies(script, &properties).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_property_references_support_qualified_and_bracket_access() {
|
||||
let script = r#"dependencies {
|
||||
implementation "g:a:${ext.x}"
|
||||
implementation "g:b:${extra.x}"
|
||||
implementation "g:c:${ext['x']}"
|
||||
implementation "g:d:${extra["x"]}"
|
||||
}"#;
|
||||
let properties = BTreeMap::from([("x".into(), "1".into())]);
|
||||
assert_eq!(script_dependencies(script, &properties).len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_are_read_recursively_and_survive_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: '$rootDir/gradle/more.gradle'\n").unwrap();
|
||||
std::fs::write(root.join("gradle/more.gradle"), "apply from: 'deps.gradle'\n").unwrap();
|
||||
|
||||
let scripts: Vec<String> = scripts_of(root, root, root, Path::new("build.gradle"))
|
||||
.into_iter()
|
||||
.map(|(path, _)| path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"))
|
||||
.collect();
|
||||
assert_eq!(scripts, ["build.gradle", "gradle/deps.gradle", "gradle/more.gradle"]);
|
||||
assert_eq!(build_files(root, &load(root).unwrap()).len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_prefer_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/deps.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/deps.gradle"), "apply from: 'versions.gradle'").unwrap();
|
||||
std::fs::write(root.join("versions.gradle"), "right").unwrap();
|
||||
std::fs::write(root.join("gradle/versions.gradle"), "wrong").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].1, "right");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_applied_paths_are_relative_to_the_project_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'gradle/b.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/b.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/b.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_fall_back_to_the_applying_script_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/a.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: 'c.gradle'").unwrap();
|
||||
std::fs::write(root.join("gradle/c.gradle"), "").unwrap();
|
||||
let scripts = scripts_of(root, root, root, Path::new("build.gradle"));
|
||||
assert_eq!(scripts.len(), 3);
|
||||
assert_eq!(scripts[2].0, root.join("gradle/c.gradle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_paths_resolve_gradle_directory_variables() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let project = root.join("app");
|
||||
let script = project.join("gradle/deps.gradle");
|
||||
std::fs::create_dir_all(script.parent().unwrap()).unwrap();
|
||||
std::fs::write(root.join("shared.gradle"), "").unwrap();
|
||||
std::fs::write(project.join("local.gradle"), "").unwrap();
|
||||
for prefix in ["$rootDir", "${rootDir}", "$rootProject.projectDir", "${rootProject.projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/shared.gradle"), &script, &project, root),
|
||||
Some(root.join("shared.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
for prefix in ["$projectDir", "${projectDir}"] {
|
||||
assert_eq!(
|
||||
resolve_script(&format!("{prefix}/local.gradle"), &script, &project, root),
|
||||
Some(project.join("local.gradle")),
|
||||
"{prefix}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
resolve_script(root.join("shared.gradle").to_str().unwrap(), &script, &project, root),
|
||||
Some(root.join("shared.gradle"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_skip_urls_and_missing_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
// Even a local path resembling a URL must not be read.
|
||||
if !cfg!(windows) {
|
||||
for scheme in ["http", "https"] {
|
||||
let local = root.join(format!("{scheme}:/example.com/deps.gradle"));
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
std::fs::write(local, "implementation 'g:remote:1'").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'http://example.com/deps.gradle'\napply(from = \"https://example.com/deps.gradle\")\napply from: 'missing.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_self_application_and_path_aliases() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(
|
||||
root.join("build.gradle"),
|
||||
"apply from: 'build.gradle'\napply from: 'gradle/../build.gradle'\napply from: 'gradle/a.gradle'",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle/a.gradle"), "apply from: '../build.gradle'").unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn applied_scripts_deduplicate_symlink_cycles() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'alias/build.gradle'").unwrap();
|
||||
std::os::unix::fs::symlink(root, root.join("alias")).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_traversal_bounds_depth_and_total_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
for i in 0..300 {
|
||||
std::fs::write(root.join(format!("{i}.gradle")), format!("apply from: '{}.gradle'", i + 1)).unwrap();
|
||||
}
|
||||
let chain = scripts_of(root, root, root, Path::new("0.gradle"));
|
||||
assert_eq!(chain.len(), 33);
|
||||
let fanout: String = (0..300).map(|i| format!("apply from: '{i}.gradle'\n")).collect();
|
||||
std::fs::write(root.join("build.gradle"), fanout).unwrap();
|
||||
assert_eq!(scripts_of(root, root, root, Path::new("build.gradle")).len(), 256);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_scripts_use_their_composite_build_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("included/app")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/build.gradle"), "").unwrap();
|
||||
std::fs::write(root.join("included/app/build.gradle"), "apply from: \"${rootDir}/deps.gradle\"").unwrap();
|
||||
std::fs::write(root.join("included/deps.gradle"), "implementation 'g:included:1'").unwrap();
|
||||
let mut model = load(root).unwrap();
|
||||
for (path, dir) in [(":included", "included"), (":included:app", "included/app")] {
|
||||
let mut project = project_at(root, path.into(), path.into(), &root.join(dir));
|
||||
project.build = Some(":included".into());
|
||||
model.projects.push(project);
|
||||
}
|
||||
assert!(build_files(root, &model).contains(&root.join("included/deps.gradle")));
|
||||
let model = crate::BuildModel { maven: None, gradle: vec![model] };
|
||||
assert_eq!(crate::dependency_coordinates(root, &model)[0].artifact_id, "included");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -147,7 +147,8 @@ pub struct DependencyCoordinate {
|
||||
|
||||
/// The external libraries of `model`, sorted and deduplicated: Maven dependencies
|
||||
/// (with the transitive ones the last sync read), except the build's own modules,
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts.
|
||||
/// `pom` imports and `system` scope; dependencies declared in the Gradle build scripts,
|
||||
/// including the scripts they pull in with `apply from`.
|
||||
pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<DependencyCoordinate> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(m) = &model.maven {
|
||||
@@ -177,11 +178,26 @@ pub fn dependency_coordinates(root: &Path, model: &BuildModel) -> Vec<Dependency
|
||||
}
|
||||
}
|
||||
for g in &model.gradle {
|
||||
for script in g.projects.iter().filter_map(|p| p.build_file.as_ref()) {
|
||||
let Ok(text) = std::fs::read_to_string(root.join(script)) else { continue };
|
||||
out.extend(gradle::script_dependencies(&text).into_iter().map(|(group_id, artifact_id, version)| {
|
||||
DependencyCoordinate { gradle: true, group_id, artifact_id, version }
|
||||
}));
|
||||
for p in &g.projects {
|
||||
let Some(build_file) = p.build_file.as_deref() else { continue };
|
||||
let build_dir = gradle::project_build_dir(root, g, p);
|
||||
let scripts = gradle::scripts_of(root, &root.join(&p.dir), &build_dir, Path::new(build_file));
|
||||
// The script's own `ext` values win over the scripts it applies.
|
||||
let mut properties = std::collections::BTreeMap::new();
|
||||
for (_, text) in scripts.iter().rev() {
|
||||
properties.extend(gradle::script_properties(text));
|
||||
}
|
||||
properties.extend(gradle::file_properties(&build_dir));
|
||||
for (_, text) in &scripts {
|
||||
out.extend(gradle::script_dependencies(text, &properties).into_iter().map(
|
||||
|(group_id, artifact_id, version)| DependencyCoordinate {
|
||||
gradle: true,
|
||||
group_id,
|
||||
artifact_id,
|
||||
version,
|
||||
},
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -312,4 +328,39 @@ mod tests {
|
||||
.collect();
|
||||
assert_eq!(names, ["M org.b:y:2", "M org.c:z:3", "G org.a:x:1.0"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_coordinates_read_scripts_brought_in_by_apply_from() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'\n").unwrap();
|
||||
std::fs::write(
|
||||
root.join("gradle/dependencies.gradle"),
|
||||
"ext { gson = '2.8.9' }\ndependencies { implementation \"com.google.code.gson:gson:${gson}\" }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let names: Vec<String> = dependency_coordinates(root, &model)
|
||||
.into_iter()
|
||||
.map(|c| format!("{}:{}:{}", c.group_id, c.artifact_id, c.version))
|
||||
.collect();
|
||||
assert_eq!(names, ["com.google.code.gson:gson:2.8.9"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_properties_override_script_extra_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("build.gradle"), "ext.v = 'wrong'\napply from: 'deps.gradle'").unwrap();
|
||||
std::fs::write(
|
||||
root.join("deps.gradle"),
|
||||
"ext.v = 'alsoWrong'\nimplementation \"g:a:${v}\"\nimplementation \"g:b:${onlyInProperties}\"",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(root.join("gradle.properties"), "# versions\nv = 1.2\nonlyInProperties: 2.3\n").unwrap();
|
||||
let model = BuildModel { maven: None, gradle: gradle::load(root).into_iter().collect() };
|
||||
let versions: Vec<_> = dependency_coordinates(root, &model).into_iter().map(|c| c.version).collect();
|
||||
assert_eq!(versions, ["1.2", "2.3"]);
|
||||
}
|
||||
}
|
||||
@@ -818,6 +818,40 @@ mod tests {
|
||||
assert!(m.stale, "gradle.properties is watched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applied_script_edits_invalidate_the_cached_gradle_model() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let cache_dir = tmp.path().join("cache");
|
||||
std::fs::create_dir_all(root.join("gradle")).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "apply from: 'gradle/dependencies.gradle'").unwrap();
|
||||
let applied = root.join("gradle/dependencies.gradle");
|
||||
std::fs::write(&applied, "implementation 'g:a:1'").unwrap();
|
||||
let model = gradle::load(&root).unwrap();
|
||||
let cache = GradleCache {
|
||||
hash: hash_gradle_files(&gradle::build_files(&root, &model)),
|
||||
projects: vec![ReportedProject {
|
||||
path: ":".into(),
|
||||
dir: root.to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
write_cache(&cache_dir, GRADLE_CACHE, &cache).unwrap();
|
||||
let stale = || {
|
||||
let mut model = gradle::load(&root).unwrap();
|
||||
apply_gradle_cache(&root, &cache_dir, &mut model);
|
||||
model.stale
|
||||
};
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "// comment\nimplementation 'g:a:1'\n").unwrap();
|
||||
assert!(!stale());
|
||||
std::fs::write(&applied, "implementation 'g:a:2'").unwrap();
|
||||
assert!(stale());
|
||||
std::fs::remove_file(&applied).unwrap();
|
||||
assert!(stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_reads_verbose_trees_with_a_pinned_plugin_unless_offline() {
|
||||
let online = maven_sync_goals(Path::new("e.xml"), false);
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
dunce.workspace = true
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod path_mapping;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Lexical path translation at protocol boundaries, independent of LSP or DAP.
|
||||
use percent_encoding::{CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
|
||||
const ESCAPES: &percent_encoding::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'`')
|
||||
.add(b'{')
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PathMapping {
|
||||
host: String,
|
||||
target: String,
|
||||
windows: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum Direction {
|
||||
ToTarget,
|
||||
ToHost,
|
||||
}
|
||||
|
||||
fn normalized(path: &str) -> String {
|
||||
let path = path
|
||||
.strip_prefix(r"\\?\UNC\")
|
||||
.map(|p| format!("//{p}"))
|
||||
.unwrap_or_else(|| path.strip_prefix(r"\\?\").unwrap_or(path).to_owned());
|
||||
let path = path.replace('\\', "/");
|
||||
if path == "/" { path } else { path.trim_end_matches('/').to_owned() }
|
||||
}
|
||||
|
||||
pub fn file_path(uri: &str) -> Option<String> {
|
||||
if !uri.get(..7)?.eq_ignore_ascii_case("file://") {
|
||||
return None;
|
||||
}
|
||||
let rest = &uri[7..];
|
||||
let path = if rest.starts_with('/') {
|
||||
rest.to_owned()
|
||||
} else {
|
||||
let (authority, path) = rest.split_once('/')?;
|
||||
if authority.eq_ignore_ascii_case("localhost") { format!("/{path}") } else { format!("//{authority}/{path}") }
|
||||
};
|
||||
let decoded = percent_decode_str(path.split(['?', '#']).next()?).decode_utf8().ok()?;
|
||||
let path = decoded.strip_prefix('/').filter(|p| p.as_bytes().get(1) == Some(&b':')).unwrap_or(&decoded);
|
||||
Some(path.to_owned())
|
||||
}
|
||||
|
||||
pub fn file_uri(path: &str) -> String {
|
||||
let path = normalized(path);
|
||||
format!("file://{}{}", if path.starts_with('/') { "" } else { "/" }, utf8_percent_encode(&path, ESCAPES))
|
||||
}
|
||||
|
||||
impl PathMapping {
|
||||
pub fn new(host: &str, target: &str) -> Self {
|
||||
let host = normalized(host);
|
||||
let windows = host.as_bytes().get(1) == Some(&b':') || host.starts_with("//");
|
||||
Self { host, target: normalized(target), windows }
|
||||
}
|
||||
|
||||
pub fn map(&self, value: &str, direction: Direction) -> Option<String> {
|
||||
let uri_path = file_path(value);
|
||||
let is_uri = uri_path.is_some();
|
||||
let path = normalized(uri_path.as_deref().unwrap_or(value));
|
||||
let (from, to) = match direction {
|
||||
Direction::ToTarget => (&self.host, &self.target),
|
||||
Direction::ToHost => (&self.target, &self.host),
|
||||
};
|
||||
let prefix = path.get(..from.len())?;
|
||||
let same = if self.windows && matches!(direction, Direction::ToTarget) {
|
||||
prefix.eq_ignore_ascii_case(from)
|
||||
} else {
|
||||
prefix == from
|
||||
};
|
||||
if !same {
|
||||
return None;
|
||||
}
|
||||
let suffix = &path[from.len()..];
|
||||
if !suffix.is_empty() && !suffix.starts_with('/') && from != "/" {
|
||||
return None;
|
||||
}
|
||||
// Never let lexical parent segments escape either mapped root.
|
||||
if suffix.split('/').any(|p| p == ".." || p == ".") {
|
||||
return None;
|
||||
}
|
||||
let mapped = format!("{}/{}", to.trim_end_matches('/'), suffix.trim_start_matches('/'));
|
||||
let mapped = if suffix.is_empty() { to.clone() } else { mapped };
|
||||
Some(if is_uri {
|
||||
let suffix = value.find(['?', '#']).map(|i| &value[i..]).unwrap_or("");
|
||||
format!("{}{suffix}", file_uri(&mapped))
|
||||
} else if self.windows && matches!(direction, Direction::ToHost) {
|
||||
mapped.replace('/', "\\")
|
||||
} else {
|
||||
mapped
|
||||
})
|
||||
}
|
||||
|
||||
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
|
||||
rewrite_strings(value, &|s| self.map(s, direction));
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrites strings and object keys (WorkspaceEdit.changes uses URIs as keys).
|
||||
/// Resolve tokens and command arguments remain opaque; source text is never a path.
|
||||
pub fn rewrite_strings(value: &mut Value, map: &dyn Fn(&str) -> Option<String>) {
|
||||
match value {
|
||||
Value::String(s) => {
|
||||
if let Some(mapped) = map(s) {
|
||||
*s = mapped;
|
||||
}
|
||||
}
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| rewrite_strings(v, map)),
|
||||
Value::Object(fields) => {
|
||||
let old = std::mem::take(fields);
|
||||
for (key, mut value) in old {
|
||||
if !matches!(key.as_str(), "data" | "arguments" | "text" | "newText" | "insertText") {
|
||||
rewrite_strings(&mut value, map);
|
||||
}
|
||||
fields.insert(map(&key).unwrap_or(key), value);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn roots_encoding_boundaries_and_windows() {
|
||||
for host in ["C:/Users/me/项目", r"C:\Users\me\项目", r"\\?\C:\Users\me\项目"] {
|
||||
let m = PathMapping::new(host, "/app");
|
||||
assert_eq!(m.map(r"\\?\c:\Users\me\项目\a b#%.php", Direction::ToTarget).unwrap(), "/app/a b#%.php");
|
||||
assert_eq!(
|
||||
m.map("file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php", Direction::ToTarget).unwrap(),
|
||||
"file:///app/a%20b%23%25.php"
|
||||
);
|
||||
assert_eq!(
|
||||
m.map("file:///app/a%20b%23%25.php", Direction::ToHost).unwrap(),
|
||||
"file:///C:/Users/me/%E9%A1%B9%E7%9B%AE/a%20b%23%25.php"
|
||||
);
|
||||
assert_eq!(m.map("/app/a.php", Direction::ToHost).unwrap(), r"C:\Users\me\项目\a.php");
|
||||
for outside in ["/app2/a", "/vendor/a", "/app/../outside", "jdt://app/a"] {
|
||||
assert!(m.map(outside, Direction::ToHost).is_none());
|
||||
}
|
||||
}
|
||||
let unc = PathMapping::new(r"\\?\UNC\server\share\project", "/app");
|
||||
assert_eq!(unc.map("file://server/share/project/a.php", Direction::ToTarget).unwrap(), "file:///app/a.php");
|
||||
assert_eq!(unc.map("/app/a.php", Direction::ToHost).unwrap(), r"\\server\share\project\a.php");
|
||||
let m = PathMapping::new("/home/me/project", "/app");
|
||||
assert_eq!(m.map("/home/me/project", Direction::ToTarget).unwrap(), "/app");
|
||||
assert_eq!(m.map("file://localhost/app/a", Direction::ToHost).unwrap(), "file:///home/me/project/a");
|
||||
assert!(m.map("/home/me/project2/a", Direction::ToTarget).is_none());
|
||||
assert_eq!(
|
||||
m.map("file:///app/a%23b?query#fragment", Direction::ToHost).unwrap(),
|
||||
"file:///home/me/project/a%23b?query#fragment"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_messages_resource_operations_and_opaque_payloads() {
|
||||
let m = PathMapping::new("/host/project", "/app");
|
||||
let mut v = json!({"rootUri":"file:///host/project", "rootPath":"/host/project", "workspaceFolders":[{"uri":"file:///host/project"}],
|
||||
"edit":{"changes":{"file:///host/project/a":[]}, "documentChanges":[{"oldUri":"file:///host/project/a", "newUri":"file:///host/project/b"}]},
|
||||
"relatedInformation":[{"location":{"uri":"file:///host/project/a"}}], "target":"file:///host/project/b",
|
||||
"configuration":[{"includePath":"/host/project/vendor"}], "text":"/host/project/a", "newText":"/host/project/b", "data":{"uri":"file:///host/project/a"}, "command":{"arguments":["/host/project/a"]}});
|
||||
let original = v.clone();
|
||||
m.rewrite(&mut v, Direction::ToTarget);
|
||||
assert_eq!(v["rootUri"], "file:///app");
|
||||
assert_eq!(v["rootPath"], "/app");
|
||||
assert!(v["edit"]["changes"].get("file:///app/a").is_some());
|
||||
assert_eq!(v["edit"]["documentChanges"][0]["newUri"], "file:///app/b");
|
||||
assert_eq!(v["relatedInformation"][0]["location"]["uri"], "file:///app/a");
|
||||
assert_eq!(v["configuration"][0]["includePath"], "/app/vendor");
|
||||
assert_eq!(v["data"], original["data"]);
|
||||
assert_eq!(v["command"], original["command"]);
|
||||
m.rewrite(&mut v, Direction::ToHost);
|
||||
assert_eq!(v, original);
|
||||
}
|
||||
}
|
||||
@@ -104,6 +104,9 @@ fn signal_group(child: &Child, signal: libc::c_int) {
|
||||
#[cfg(windows)]
|
||||
const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
|
||||
#[cfg(windows)]
|
||||
pub use windows_tree::ProcessTree;
|
||||
|
||||
/// `taskkill /T /F` from `pid`, then waits (up to [`TREE_EXIT_WAIT`]) until what the child
|
||||
/// started is gone: termination is asynchronous, and until a process is gone the files it
|
||||
/// holds open can't be opened or deleted. The child itself is left to its owner to reap.
|
||||
@@ -136,13 +139,48 @@ mod windows_tree {
|
||||
CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, WaitForSingleObject,
|
||||
GetProcessTimes, OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE,
|
||||
TerminateProcess, WaitForSingleObject,
|
||||
};
|
||||
|
||||
/// Owned handles to a program and its current descendants, retained even if its launcher exits.
|
||||
pub struct ProcessTree {
|
||||
processes: Vec<OwnedHandle>,
|
||||
}
|
||||
|
||||
impl ProcessTree {
|
||||
pub fn capture(pid: u32) -> Self {
|
||||
let Some(root) = open_with(pid, PROCESS_TERMINATE) else { return Self { processes: Vec::new() } };
|
||||
let children = descendants_with(pid, PROCESS_TERMINATE);
|
||||
Self { processes: std::iter::once(root).chain(children).collect() }
|
||||
}
|
||||
|
||||
pub fn is_running(&self) -> bool {
|
||||
self.processes.iter().any(|process| {
|
||||
// SAFETY: an owned process handle opened with SYNCHRONIZE access.
|
||||
unsafe {
|
||||
WaitForSingleObject(process.as_raw_handle(), 0) == windows_sys::Win32::Foundation::WAIT_TIMEOUT
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Requests termination children first; poll `is_running` until termination completes.
|
||||
pub fn terminate(&self) {
|
||||
for process in self.processes.iter().rev() {
|
||||
// SAFETY: an owned process handle opened with TERMINATE access, never a reused pid.
|
||||
unsafe { TerminateProcess(process.as_raw_handle(), 1) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every process `root` started, directly or not, opened so it can be waited for. A
|
||||
/// parent's id is reused once it is gone, so a process counts only if it started after
|
||||
/// its parent.
|
||||
pub(super) fn descendants(root: u32) -> Vec<OwnedHandle> {
|
||||
descendants_with(root, 0)
|
||||
}
|
||||
|
||||
fn descendants_with(root: u32, access: u32) -> Vec<OwnedHandle> {
|
||||
let Some(root_started) = open(root).as_ref().and_then(started) else { return Vec::new() };
|
||||
let processes = processes();
|
||||
let mut seen = HashSet::from([root]);
|
||||
@@ -153,7 +191,7 @@ mod windows_tree {
|
||||
if ppid != parent || !seen.insert(pid) {
|
||||
continue;
|
||||
}
|
||||
let Some(handle) = open(pid) else { continue };
|
||||
let Some(handle) = open_with(pid, access) else { continue };
|
||||
if let Some(at) = started(&handle).filter(|&at| at >= parent_started) {
|
||||
parents.push((pid, at));
|
||||
found.push(handle);
|
||||
@@ -193,8 +231,12 @@ mod windows_tree {
|
||||
}
|
||||
|
||||
fn open(pid: u32) -> Option<OwnedHandle> {
|
||||
open_with(pid, 0)
|
||||
}
|
||||
|
||||
fn open_with(pid: u32, access: u32) -> Option<OwnedHandle> {
|
||||
// SAFETY: plain call; a non-null result is a process handle owned by the caller.
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION | access, 0, pid) };
|
||||
// SAFETY: as above.
|
||||
(!handle.is_null()).then(|| unsafe { OwnedHandle::from_raw_handle(handle) })
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ pub struct Settings {
|
||||
/// Per language server, by name (`rust-analyzer`): launch and protocol
|
||||
/// options on top of the language definition, or a server defined here.
|
||||
pub language_servers: BTreeMap<String, LanguageServerSettings>,
|
||||
/// Project language id → server and plugin target. Empty target uses the host.
|
||||
pub language_server_targets: BTreeMap<String, LanguageServerTarget>,
|
||||
/// Editor › Inspections profiles.
|
||||
pub inspections: InspectionSettings,
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
@@ -139,6 +141,14 @@ pub struct ToolSettings {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerTarget {
|
||||
pub target: String,
|
||||
/// Empty selects the first configured server for the language.
|
||||
pub server: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerSettings {
|
||||
@@ -233,6 +243,7 @@ impl Default for Settings {
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
language_servers: BTreeMap::new(),
|
||||
language_server_targets: BTreeMap::new(),
|
||||
inspections: InspectionSettings::default(),
|
||||
spelling: SpellingSettings::default(),
|
||||
ssh: SshSettings::default(),
|
||||
|
||||
@@ -24,5 +24,7 @@ tracing.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-remote.workspace = true
|
||||
ide-run.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
+151
-83
@@ -31,6 +31,7 @@ pub enum Transport {
|
||||
/// A fully resolved adapter command line.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct AdapterSpec {
|
||||
pub target: Option<crate::target::TargetMapping>,
|
||||
pub adapter: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
@@ -51,6 +52,7 @@ pub struct AdapterSpec {
|
||||
/// Built-in adapters used when no language definition names `adapter`.
|
||||
fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
let cfg = |command: &str, args: &[&str], transport: &str, hint: &str| DebuggerConfig {
|
||||
target_placement: None,
|
||||
adapter: adapter.to_owned(),
|
||||
command: command.to_owned(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
@@ -134,6 +136,7 @@ pub fn default_type(adapter: &str) -> Option<&'static str> {
|
||||
|
||||
pub fn resolve(config: &DebuggerConfig, cwd: PathBuf) -> AdapterSpec {
|
||||
AdapterSpec {
|
||||
target: None,
|
||||
adapter: config.adapter.clone(),
|
||||
command: config.command.clone(),
|
||||
args: config.args.clone(),
|
||||
@@ -166,6 +169,11 @@ pub fn parse_address(addr: &str) -> Option<(String, u16)> {
|
||||
}
|
||||
|
||||
/// A port that was free a moment ago on the loopback interface.
|
||||
///
|
||||
/// Inherently racy: another process can bind the port between the listener being
|
||||
/// dropped here and the adapter binding it. Callers that hand the port to a child
|
||||
/// retry on a bind conflict (see [`ProcessLauncher`]), or — better — let the child
|
||||
/// pick `:0` and report the actual port back (as the Kotlin JDWP debuggee does).
|
||||
pub fn free_port() -> std::io::Result<u16> {
|
||||
let listener = std::net::TcpListener::bind(("127.0.0.1", 0))?;
|
||||
Ok(listener.local_addr()?.port())
|
||||
@@ -394,6 +402,9 @@ const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const EMBEDDED_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// Probe of a `connect` address before starting the command.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
/// Attempts at a fresh port for a TCP adapter: the one `free_port` hands out can be
|
||||
/// claimed by another process before the adapter binds it, so a new one usually wins.
|
||||
const PORT_ATTEMPTS: u32 = 3;
|
||||
|
||||
impl AdapterLauncher for ProcessLauncher {
|
||||
fn launch(&self, mut spec: AdapterSpec, reuse_port: Option<u16>) -> LaunchFuture {
|
||||
@@ -439,7 +450,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
if spec.adapter == DEBUGPY_TOOL && spec.target.is_none() {
|
||||
prepare_debugpy(&mut spec).await?;
|
||||
}
|
||||
if is_plugin_bundle(&spec.command) {
|
||||
@@ -459,95 +470,140 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.ok_or_else(|| missing(&spec))?
|
||||
}
|
||||
};
|
||||
let port = match spec.transport {
|
||||
// An embedded adapter started without `${port}` listens on its usual address.
|
||||
Transport::Tcp => match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => Some(free_port()?),
|
||||
},
|
||||
Transport::Stdio => None,
|
||||
};
|
||||
let args = substitute_root(&spec.args, &spec.cwd);
|
||||
let args = match port {
|
||||
Some(port) => substitute_port(&args, port),
|
||||
None => args,
|
||||
};
|
||||
let mut cmd = Command::new(&binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
}
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
let base_args = substitute_root(&spec.args, &spec.cwd);
|
||||
match spec.transport {
|
||||
Transport::Stdio => spawn_adapter(&spec, &binary, &base_args, None, CONNECT_TIMEOUT).await,
|
||||
Transport::Tcp => {
|
||||
// An embedded adapter started without `${port}` listens on its usual
|
||||
// address; every other TCP adapter gets a port chosen here so it can be
|
||||
// retried if another process takes it before the adapter binds.
|
||||
let fixed = match &embedded {
|
||||
Some((_, port)) if !spec.args.iter().any(|a| a.contains(PORT_PLACEHOLDER)) => Some(*port),
|
||||
_ => None,
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
let start_timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
for attempt in 1..=PORT_ATTEMPTS {
|
||||
let port = match fixed {
|
||||
Some(port) => port,
|
||||
None => free_port()?,
|
||||
};
|
||||
let args = substitute_port(&base_args, port);
|
||||
match spawn_adapter(&spec, &binary, &args, Some(port), start_timeout).await {
|
||||
Ok(io) => return Ok(io),
|
||||
Err(e) => {
|
||||
if fixed.is_none() && attempt < PORT_ATTEMPTS && is_port_race(&e) {
|
||||
tracing::warn!(
|
||||
target: "ide_dap::adapter",
|
||||
"debug adapter failed to start on port {port}: {}; retrying",
|
||||
e.message
|
||||
);
|
||||
continue;
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
unreachable!("the retry loop returns on its last attempt")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts `binary` with `args`, then connects to it when `port` is set (a TCP adapter)
|
||||
/// or hands back its stdio when it is not. The child is stopped if it never accepts.
|
||||
async fn spawn_adapter(
|
||||
spec: &AdapterSpec,
|
||||
binary: &Path,
|
||||
args: &[String],
|
||||
port: Option<u16>,
|
||||
start_timeout: Duration,
|
||||
) -> IdeResult<AdapterIo> {
|
||||
let stderr = Arc::new(parking_lot::Mutex::new(Vec::new()));
|
||||
let mut cmd = Command::new(binary);
|
||||
if let Some(path) = adapter_path(&spec.env) {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
}
|
||||
cmd.args(args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
.stdin(if port.is_some() { Stdio::null() } else { Stdio::piped() })
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
let stdin = child.stdin.take().ok_or_else(|| IdeError::internal("adapter stdin unavailable"))?;
|
||||
let stdout = child.stdout.take().ok_or_else(|| IdeError::internal("adapter stdout unavailable"))?;
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(stdout),
|
||||
writer: Box::new(stdin),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), start_timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
e
|
||||
} else {
|
||||
IdeError::unavailable(format!("{}\n{tail}", e.message))
|
||||
});
|
||||
}
|
||||
};
|
||||
let (r, w) = stream.into_split();
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed adapter start that another process grabbing the free port can explain
|
||||
/// (POSIX and Windows "address already in use" texts, or the adapter exiting because
|
||||
/// it could not bind). Anything else is a real start failure and is not retried.
|
||||
fn is_port_race(err: &IdeError) -> bool {
|
||||
let message = err.message.to_ascii_lowercase();
|
||||
message.contains("address already in use")
|
||||
|| message.contains("addrinuse")
|
||||
|| message.contains("only one usage of each socket address")
|
||||
|| message.contains("exited before accepting connections")
|
||||
}
|
||||
|
||||
fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
@@ -775,4 +831,16 @@ mod tests {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
"debug adapter exited before accepting connections (exit status: 1)\nbind: address already in use",
|
||||
);
|
||||
assert!(is_port_race(&in_use));
|
||||
assert!(is_port_race(&IdeError::unavailable(
|
||||
"Only one usage of each socket address (protocol/network address/port) is normally permitted."
|
||||
)));
|
||||
assert!(!is_port_race(&IdeError::unavailable("failed to start codelldb: file not found")));
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,7 @@ pub enum Incoming {
|
||||
type Pending = HashMap<i64, (String, oneshot::Sender<Result<Value, DapError>>)>;
|
||||
|
||||
struct Inner {
|
||||
mapping: Option<crate::target::TargetMapping>,
|
||||
out: mpsc::UnboundedSender<Vec<u8>>,
|
||||
pending: Mutex<Pending>,
|
||||
seq: AtomicI64,
|
||||
@@ -106,9 +107,22 @@ impl Connection {
|
||||
/// [`start`](Self::start) that hands lines outside the protocol (an adapter's
|
||||
/// own output on stdout, decoded) to `on_output`.
|
||||
pub fn start_with_output<R, W>(
|
||||
reader: R,
|
||||
writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start_with_target(reader, writer, on_output, None)
|
||||
}
|
||||
|
||||
pub fn start_with_target<R, W>(
|
||||
reader: R,
|
||||
mut writer: W,
|
||||
on_output: Box<dyn Fn(String) + Send + Sync>,
|
||||
target: Option<crate::target::TargetMapping>,
|
||||
) -> (Self, mpsc::UnboundedReceiver<Incoming>)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
@@ -117,6 +131,7 @@ impl Connection {
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (in_tx, in_rx) = mpsc::unbounded_channel();
|
||||
let inner = Arc::new(Inner {
|
||||
mapping: target,
|
||||
out: out_tx,
|
||||
pending: Mutex::new(HashMap::new()),
|
||||
seq: AtomicI64::new(1),
|
||||
@@ -187,7 +202,11 @@ impl Connection {
|
||||
if self.is_closed() {
|
||||
return Err(DapError::Closed);
|
||||
}
|
||||
let body = serde_json::to_vec(value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
let mut value = value.clone();
|
||||
if let Some(mapping) = self.inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut value, ide_core::path_mapping::Direction::ToTarget);
|
||||
}
|
||||
let body = serde_json::to_vec(&value).map_err(|e| DapError::Protocol(e.to_string()))?;
|
||||
tracing::trace!(target: "ide_dap::wire", "--> {}", String::from_utf8_lossy(&body));
|
||||
self.inner.out.send(body).map_err(|_| DapError::Closed)
|
||||
}
|
||||
@@ -246,13 +265,16 @@ impl Connection {
|
||||
|
||||
fn dispatch(inner: &Inner, incoming: &mpsc::UnboundedSender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_dap::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("dap: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(mapping) = inner.mapping.as_ref() {
|
||||
mapping.rewrite(&mut msg, ide_core::path_mapping::Direction::ToHost);
|
||||
}
|
||||
match msg.get("type").and_then(Value::as_str) {
|
||||
Some("response") => {
|
||||
let Some(request_seq) = msg.get("request_seq").and_then(Value::as_i64) else {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub mod jdwp;
|
||||
pub mod kotlin;
|
||||
mod kotlin_jdwp;
|
||||
mod session;
|
||||
pub mod target;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
@@ -607,11 +608,22 @@ fn remove_delve_binaries(cwd: &Path) {
|
||||
|| s == ".exe"
|
||||
|| (s.starts_with(".exe") && s[".exe".len()..].chars().all(|c| c.is_ascii_digit()))
|
||||
});
|
||||
if leftover
|
||||
&& entry.file_type().is_ok_and(|t| t.is_file())
|
||||
&& let Err(e) = std::fs::remove_file(entry.path())
|
||||
{
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
if !leftover || entry.file_type().is_ok_and(|t| !t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
// Windows: a just-killed dlv / debuggee can still hold this session's
|
||||
// binary while the previous one's lock is already gone, so one try
|
||||
// leaves exactly the current leftover behind. Retry on a short back-off
|
||||
// to outlast the handle release; a truly busy file is retried into the
|
||||
// next run's sweep anyway.
|
||||
for attempt in 0..5 {
|
||||
match std::fs::remove_file(entry.path()) {
|
||||
Ok(()) => break,
|
||||
Err(e) if attempt == 4 => {
|
||||
tracing::debug!("could not remove Delve leftover {}: {e}", entry.path().display());
|
||||
}
|
||||
Err(_) => std::thread::sleep(std::time::Duration::from_millis(50 * (attempt as u64 + 1))),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -685,6 +697,17 @@ impl DebugManager {
|
||||
config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
self.start_prepared(config, owner, custom_command, |_, _| Ok(())).await
|
||||
}
|
||||
|
||||
/// Configure a target adapter before any host tool resolution or launch.
|
||||
pub async fn start_prepared(
|
||||
&self,
|
||||
config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
custom_command: impl FnOnce(&str) -> Option<String>,
|
||||
prepare: impl FnOnce(&mut AdapterSpec, &mut Value) -> IdeResult<()>,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let mut arguments = launch_arguments(&config)?;
|
||||
// An IDE-side marker (never sent to the adapter): the session is a Flutter
|
||||
@@ -699,6 +722,22 @@ impl DebugManager {
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
prepare(&mut spec, &mut arguments)?;
|
||||
if spec.target.is_some() {
|
||||
return self
|
||||
.start_session(
|
||||
DebugSessionId::next(),
|
||||
owner,
|
||||
config.name,
|
||||
config.adapter,
|
||||
None,
|
||||
config.request,
|
||||
arguments,
|
||||
spec,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
.as_object_mut()
|
||||
@@ -847,11 +886,22 @@ impl DebugManager {
|
||||
if request != "launch" && request != "attach" {
|
||||
return Err(IdeError::invalid(format!("request must be `launch` or `attach`, got `{request}`")));
|
||||
}
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await?;
|
||||
let io = self.launcher.launch(spec.clone(), reuse_port).await.map_err(|e| {
|
||||
if spec.target.is_some() {
|
||||
IdeError::new(e.kind, format!("{}\nargv: {:?} {:?}", e.message, spec.command, spec.args))
|
||||
} else {
|
||||
e
|
||||
}
|
||||
})?;
|
||||
let stderr = io.stderr.clone();
|
||||
let (conn, incoming) = {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
Connection::start_with_target(
|
||||
io.reader,
|
||||
io.writer,
|
||||
Box::new(move |line| adapter::push_line(&stderr, line)),
|
||||
spec.target.clone(),
|
||||
)
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
@@ -892,6 +942,9 @@ impl DebugManager {
|
||||
Err(e) => {
|
||||
let e = if session.conn.is_closed() {
|
||||
let mut msg = format!("debug adapter `{}` exited during startup", session.adapter);
|
||||
if session.spec.target.is_some() {
|
||||
msg.push_str(&format!("\nargv: {:?} {:?}", session.spec.command, session.spec.args));
|
||||
}
|
||||
let tail = stderr.lock().join("\n");
|
||||
if !tail.is_empty() {
|
||||
msg.push_str(":\n");
|
||||
@@ -902,6 +955,17 @@ impl DebugManager {
|
||||
msg.push_str(hint);
|
||||
}
|
||||
IdeError::unavailable(msg)
|
||||
} else if session.spec.target.is_some() {
|
||||
IdeError::new(
|
||||
e.kind,
|
||||
format!(
|
||||
"{}\nargv: {:?} {:?}\n{}",
|
||||
e.message,
|
||||
session.spec.command,
|
||||
session.spec.args,
|
||||
stderr.lock().join("\n")
|
||||
),
|
||||
)
|
||||
} else {
|
||||
e
|
||||
};
|
||||
@@ -939,8 +1003,8 @@ impl DebugManager {
|
||||
"pathFormat": "path",
|
||||
"supportsVariableType": true,
|
||||
"supportsVariablePaging": false,
|
||||
"supportsRunInTerminalRequest": true,
|
||||
"supportsStartDebuggingRequest": true,
|
||||
"supportsRunInTerminalRequest": s.spec.target.is_none(),
|
||||
"supportsStartDebuggingRequest": s.spec.target.is_none(),
|
||||
"supportsProgressReporting": true,
|
||||
"supportsInvalidatedEvent": false,
|
||||
"supportsMemoryReferences": false,
|
||||
@@ -1066,6 +1130,9 @@ impl DebugManager {
|
||||
|
||||
/// Ends a session: `terminate` (when asked and supported) → `disconnect` → kill.
|
||||
pub(crate) async fn shutdown(&self, s: &Arc<Session>, terminate: bool) {
|
||||
if terminate {
|
||||
crate::session::remember_debuggee(s).await;
|
||||
}
|
||||
// java-debug runs the program itself and pipes its stdout over DAP. A `disconnect`
|
||||
// ends that DAP session, so every line the program writes while shutting down is lost.
|
||||
// Ask the program to exit ourselves and keep the adapter attached until it has drained
|
||||
|
||||
@@ -59,6 +59,8 @@ pub(crate) struct Session {
|
||||
/// adapter may report the launcher shell, whose child is the program).
|
||||
#[cfg_attr(not(unix), allow(dead_code))]
|
||||
pub stop_pids: parking_lot::Mutex<Vec<i32>>,
|
||||
#[cfg(windows)]
|
||||
pub stop_tree: Mutex<Option<ide_core::process::ProcessTree>>,
|
||||
/// Breakpoints replaced for run to cursor / force stepping, restored on the next stop.
|
||||
pub pending_restore: Mutex<PendingRestore>,
|
||||
/// Adapter breakpoint id → (file, index in the stored list).
|
||||
@@ -111,6 +113,8 @@ impl Session {
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
stop_pids: parking_lot::Mutex::new(Vec::new()),
|
||||
#[cfg(windows)]
|
||||
stop_tree: Mutex::new(None),
|
||||
pending_restore: Mutex::default(),
|
||||
breakpoint_ids: Mutex::new(HashMap::new()),
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
@@ -298,6 +302,21 @@ impl Session {
|
||||
/// How long Stop waits for a launched program to exit before reporting the session ended.
|
||||
pub(crate) const DEBUGGEE_EXIT_WAIT: Duration = Duration::from_secs(10);
|
||||
|
||||
pub(crate) async fn remember_debuggee(s: &Arc<Session>) {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Ok(pid) = u32::try_from(s.debuggee_pid.load(Ordering::Acquire)) else { return };
|
||||
if s.request != "launch" || pid == 0 || s.stop_tree.lock().is_some() {
|
||||
return;
|
||||
}
|
||||
if let Ok(tree) = tokio::task::spawn_blocking(move || ide_core::process::ProcessTree::capture(pid)).await {
|
||||
s.stop_tree.lock().get_or_insert(tree);
|
||||
}
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
let _ = s;
|
||||
}
|
||||
|
||||
/// The program and everything it started: an adapter may report the launcher shell, whose
|
||||
/// child is the debugged program, so stopping only the reported pid leaves the program alive.
|
||||
#[cfg(unix)]
|
||||
@@ -349,12 +368,10 @@ pub(crate) fn signal_debuggee(s: &Arc<Session>, force: bool) {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("taskkill");
|
||||
cmd.args(["/PID", &pid.to_string(), "/T"]);
|
||||
if force {
|
||||
cmd.arg("/F");
|
||||
// Console programs have no external graceful signal; the adapter gets its chance first.
|
||||
if force && let Some(tree) = s.stop_tree.lock().as_ref() {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = cmd.status();
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let _ = (pid, force);
|
||||
@@ -391,7 +408,18 @@ pub(crate) async fn wait_for_debuggee(s: &Arc<Session>, terminate: bool) {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
{
|
||||
remember_debuggee(s).await;
|
||||
let deadline = std::time::Instant::now() + DEBUGGEE_EXIT_WAIT;
|
||||
while s.stop_tree.lock().as_ref().is_some_and(|tree| tree.is_running()) {
|
||||
if s.stop_force.swap(false, Ordering::AcqRel) || std::time::Instant::now() >= deadline {
|
||||
signal_debuggee(s, true);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
let _ = pid;
|
||||
}
|
||||
@@ -579,6 +607,10 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
}
|
||||
|
||||
fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, command: &str, args: Value) {
|
||||
if s.spec.target.is_some() {
|
||||
let _ = s.conn.respond(seq, command, Err("This debugger does not support plugin run targets".into()));
|
||||
return;
|
||||
}
|
||||
match command {
|
||||
"runInTerminal" => {
|
||||
let result = run_in_terminal(s, &args);
|
||||
@@ -791,7 +823,6 @@ fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream
|
||||
/// Launch with the IDE's resolved runtime and classpath; the adapter only attaches.
|
||||
pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) -> ide_core::IdeResult<()> {
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
let port = crate::adapter::free_port()?;
|
||||
let java = args.get("javaExec").and_then(Value::as_str).unwrap_or("java");
|
||||
let strings = |key: &str| -> Vec<String> {
|
||||
args.get(key)
|
||||
@@ -804,7 +835,9 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
let mut cmd = tokio::process::Command::new(java);
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
cmd.args(strings("vmArguments"))
|
||||
.arg(format!("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:{port}"))
|
||||
// Let the JVM pick its own port (`:0`) and report it below: pre-picking a free
|
||||
// port here races with other processes that may take it before the JVM binds.
|
||||
.arg("-agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=127.0.0.1:0")
|
||||
.arg("-cp")
|
||||
.arg(classpath);
|
||||
let modules = strings("modulePaths");
|
||||
@@ -837,14 +870,18 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
}
|
||||
let mut stdout =
|
||||
BufReader::new(child.stdout.take().ok_or_else(|| ide_core::IdeError::internal("Missing JVM stdout"))?);
|
||||
tokio::time::timeout(Duration::from_secs(15), async {
|
||||
// The JVM chose the port (`:0` above) and reports it here; reading it back removes
|
||||
// the free-port race window entirely.
|
||||
let port = tokio::time::timeout(Duration::from_secs(15), async {
|
||||
loop {
|
||||
let mut line = String::new();
|
||||
if stdout.read_line(&mut line).await? == 0 {
|
||||
return Err(ide_core::IdeError::unavailable("JVM exited before starting its debug listener"));
|
||||
}
|
||||
if line.contains("Listening for transport dt_socket") {
|
||||
return Ok(());
|
||||
return parse_jdwp_port(&line).ok_or_else(|| {
|
||||
ide_core::IdeError::unavailable(format!("JVM reported an unreadable debug port: {}", line.trim()))
|
||||
});
|
||||
}
|
||||
s.emit(DebugEvent::Output { category: "stdout".into(), output: line });
|
||||
}
|
||||
@@ -858,3 +895,22 @@ pub(crate) async fn start_kotlin_debuggee(s: &Arc<Session>, args: &mut Value) ->
|
||||
args["timeout"] = json!(10000);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The port a JVM chose, from `Listening for transport dt_socket at address: <port>`
|
||||
/// (some JVMs print `host:port` or `[::1]:port`, so take what follows the last colon).
|
||||
fn parse_jdwp_port(line: &str) -> Option<u16> {
|
||||
line.rsplit_once("address:")?.1.trim().rsplit(':').next()?.trim().parse().ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_jdwp_port;
|
||||
|
||||
#[test]
|
||||
fn reads_the_port_the_jvm_chose() {
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: 127.0.0.1:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("Listening for transport dt_socket at address: [::1]:51234"), Some(51234));
|
||||
assert_eq!(parse_jdwp_port("unrelated output"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! DAP path fields only: output, expressions, environment and program arguments are opaque.
|
||||
use ide_core::path_mapping::{Direction, PathMapping, file_path, file_uri};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TargetMapping {
|
||||
pub paths: PathMapping,
|
||||
pub external_prefix: String,
|
||||
}
|
||||
|
||||
impl TargetMapping {
|
||||
pub fn rewrite(&self, value: &mut Value, direction: Direction) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(|v| self.rewrite(v, direction)),
|
||||
Value::Object(fields) => {
|
||||
for (key, value) in fields {
|
||||
if key == "pathMappings" {
|
||||
continue;
|
||||
}
|
||||
if matches!(key.as_str(), "path" | "program" | "cwd" | "localRoot" | "remoteRoot") {
|
||||
if let Some(path) = value.as_str() {
|
||||
if matches!(direction, Direction::ToTarget) && path.starts_with(&self.external_prefix) {
|
||||
if let Some(decoded) =
|
||||
file_path(&format!("file:///{}", path.trim_start_matches(&self.external_prefix)))
|
||||
{
|
||||
*value = decoded.into();
|
||||
}
|
||||
} else if let Some(mapped) = self.paths.map(path, direction) {
|
||||
*value = mapped.into();
|
||||
} else if matches!(direction, Direction::ToHost) && key == "path" && path.starts_with('/') {
|
||||
*value = format!(
|
||||
"{}{}",
|
||||
self.external_prefix,
|
||||
file_uri(path).trim_start_matches("file:///")
|
||||
)
|
||||
.into();
|
||||
}
|
||||
}
|
||||
} else if !matches!(key.as_str(), "args" | "env" | "output" | "expression" | "value") {
|
||||
self.rewrite(value, direction);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
#[test]
|
||||
fn maps_protocol_paths_without_changing_user_data_or_adapter_mappings() {
|
||||
let m = TargetMapping {
|
||||
paths: PathMapping::new("C:/project", "/app"),
|
||||
external_prefix: "openidea-target://test/0/".into(),
|
||||
};
|
||||
let mut request = json!({"arguments":{"source":{"path":"C:/project/a.py"},"program":"C:/project/a.py","cwd":"C:/project", "args":["C:/project/text"],"env":{"path":"C:/project/text"},"pathMappings":[{"localRoot":"/app","remoteRoot":"/app"}]}});
|
||||
m.rewrite(&mut request, Direction::ToTarget);
|
||||
assert_eq!(request["arguments"]["source"]["path"], "/app/a.py");
|
||||
assert_eq!(request["arguments"]["cwd"], "/app");
|
||||
assert_eq!(request["arguments"]["args"][0], "C:/project/text");
|
||||
assert_eq!(request["arguments"]["env"]["path"], "C:/project/text");
|
||||
let mut response = json!({"body":{"stackFrames":[{"source":{"path":"/app/a.py"}},{"source":{"path":"/usr/lib/a.py"}}],"sources":[{"path":"/app/b.py"}],"output":"/app/a.py"}});
|
||||
m.rewrite(&mut response, Direction::ToHost);
|
||||
assert_eq!(response["body"]["stackFrames"][0]["source"]["path"], "C:\\project\\a.py");
|
||||
assert_eq!(response["body"]["sources"][0]["path"], "C:\\project\\b.py");
|
||||
assert_eq!(response["body"]["stackFrames"][1]["source"]["path"], "openidea-target://test/0/usr/lib/a.py");
|
||||
assert_eq!(response["body"]["output"], "/app/a.py");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,93 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
|
||||
type Log = Arc<Mutex<Vec<Value>>>;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "child process for Windows stop tests"]
|
||||
fn windows_debuggee_process() {
|
||||
if std::env::var_os("OPENIDEA_TEST_DEBUGGEE").is_none() {
|
||||
return;
|
||||
}
|
||||
if std::env::var("OPENIDEA_TEST_DEBUGGEE").unwrap() == "wrapper" {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
let mut child = ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", "leaf")
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let _ = child.wait();
|
||||
return;
|
||||
}
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
println!("LISTENING {}", listener.local_addr().unwrap().port());
|
||||
std::thread::sleep(Duration::from_secs(60));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[tokio::test]
|
||||
async fn windows_stop_releases_the_debuggees_port_before_returning() {
|
||||
use std::io::BufRead;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
struct Child(std::process::Child, Option<ide_core::process::ProcessTree>);
|
||||
impl Drop for Child {
|
||||
fn drop(&mut self) {
|
||||
if let Some(tree) = &self.1 {
|
||||
tree.terminate();
|
||||
}
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
for (second_stop, wrapper, supports_terminate) in
|
||||
[(false, false, false), (true, false, false), (true, true, false), (true, true, true)]
|
||||
{
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
ide_core::process::prepare(&mut command)
|
||||
.args(["--exact", "tests::windows_debuggee_process", "--ignored", "--nocapture"])
|
||||
.env("OPENIDEA_TEST_DEBUGGEE", if wrapper { "wrapper" } else { "leaf" })
|
||||
.stdout(std::process::Stdio::piped());
|
||||
let mut child = Child(command.spawn().unwrap(), None);
|
||||
let port: u16 = std::io::BufReader::new(child.0.stdout.take().unwrap())
|
||||
.lines()
|
||||
.map(Result::unwrap)
|
||||
.find_map(|line| line.split("LISTENING ").nth(1).and_then(|p| p.parse().ok()))
|
||||
.unwrap();
|
||||
let pid = child.0.id();
|
||||
child.1 = Some(ide_core::process::ProcessTree::capture(pid));
|
||||
let h = harness(Script { debuggee_pid: Some(pid), supports_terminate, ..Default::default() });
|
||||
let id = h.start(config()).await.unwrap();
|
||||
let session = h.manager.session(id).unwrap();
|
||||
while session.debuggee_pid.load(Ordering::Acquire) != i64::from(pid) {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
let stop = tokio::spawn({
|
||||
let manager = h.manager.clone();
|
||||
async move { manager.stop(id).await }
|
||||
});
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(5);
|
||||
while session.stop_tree.lock().is_none() && !stop.is_finished() {
|
||||
assert!(std::time::Instant::now() < deadline, "Stop did not capture the program tree");
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(!stop.is_finished(), "Stop must wait for the live program");
|
||||
if wrapper {
|
||||
assert!(session.stop_tree.lock().is_some());
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
assert!(std::net::TcpListener::bind(("127.0.0.1", port)).is_err(), "the child still owns its port");
|
||||
}
|
||||
if second_stop {
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
let limit = if second_stop { 10 } else { 25 };
|
||||
tokio::time::timeout(Duration::from_secs(limit), stop).await.unwrap().unwrap().unwrap();
|
||||
assert!(child.0.try_wait().unwrap().is_some(), "Stop returned with the program alive");
|
||||
let _listener = std::net::TcpListener::bind(("127.0.0.1", port)).expect("Stop returned with its port occupied");
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Script {
|
||||
/// Send a `runInTerminal` reverse request when `launch` arrives.
|
||||
@@ -1166,6 +1253,7 @@ fn godot_registry(port: u16, command: &str, args: &[&str]) -> Arc<LanguageRegist
|
||||
tree_sitter: None,
|
||||
language_servers: vec![],
|
||||
debugger: Some(ide_language::DebuggerConfig {
|
||||
target_placement: None,
|
||||
adapter: "godot".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|a| (*a).to_owned()).collect(),
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
import sys,json,os,socket
|
||||
root=os.environ.get("FAKE_ROOT","/app")
|
||||
r=sys.stdin.buffer; w=sys.stdout.buffer
|
||||
seq=0
|
||||
def send(v):
|
||||
global seq
|
||||
seq+=1; v['seq']=seq
|
||||
b=json.dumps(v).encode(); w.write(('Content-Length: %d\r\n\r\n'%len(b)).encode()+b); w.flush()
|
||||
while True:
|
||||
line=r.readline()
|
||||
if not line: break
|
||||
n=int(line.split(b':')[1]); assert r.readline()==b'\r\n'
|
||||
q=json.loads(r.read(n)); c=q['command']; a=q.get('arguments',{}); body={}
|
||||
if c=='initialize': body={'supportsConfigurationDoneRequest':True}
|
||||
if c=='launch':
|
||||
if 'program' in a: assert a['program']==root+'/main.py',a
|
||||
if os.environ.get('FAKE_CONNECT_BACK'):
|
||||
listener=socket.socket(); listener.bind(('127.0.0.1',a['port'])); listener.listen()
|
||||
assert a['cwd']==root,a
|
||||
send({'type':'event','event':'initialized'})
|
||||
if c=='setBreakpoints':
|
||||
assert a['source']['path']==root+'/main.py',a
|
||||
body={'breakpoints':[{'verified':True,'line':3}]}
|
||||
if c=='threads': body={'threads':[{'id':1,'name':'target main'}]}
|
||||
if c=='configurationDone': send({'type':'event','event':'stopped','body':{'reason':'breakpoint','threadId':1,'allThreadsStopped':True}})
|
||||
if c=='stackTrace' and os.environ.get('FAKE_CONNECT_BACK'):
|
||||
client,_=listener.accept(); assert client.recv(4096).decode()==root+'/main.py'; client.close()
|
||||
if c=='stackTrace': body={'stackFrames':[{'id':1,'name':'main','line':3,'column':1,'source':{'path':root+'/main.py'}}]}
|
||||
send({'type':'response','request_seq':q['seq'],'command':c,'success':True,'body':body})
|
||||
if c=='disconnect': break
|
||||
@@ -93,9 +93,23 @@ async fn listens_for_xdebug_until_stopped_with_path_mappings() {
|
||||
.await
|
||||
.unwrap();
|
||||
let id = manager
|
||||
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
|
||||
Some(adapter.clone())
|
||||
})
|
||||
.start_prepared(
|
||||
listen_config(port, &served, &served),
|
||||
SessionOwner::new("main", sink.clone()),
|
||||
|_| None,
|
||||
|spec, _| {
|
||||
spec.command = adapter.clone();
|
||||
spec.cwd = local.clone();
|
||||
spec.target = Some(ide_dap::target::TargetMapping {
|
||||
paths: ide_core::path_mapping::PathMapping::new(
|
||||
&local.to_string_lossy(),
|
||||
&served.to_string_lossy(),
|
||||
),
|
||||
external_prefix: "openidea-target://php/0/".into(),
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("start: {e:?}"));
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
//! Real stdio transport through a target wrapper, without Docker or installed adapters.
|
||||
#![cfg(unix)]
|
||||
use ide_core::{SharedSink, path_mapping::PathMapping};
|
||||
use ide_dap::{target::TargetMapping, *};
|
||||
use ide_language::LanguageRegistry;
|
||||
use std::{collections::BTreeMap, sync::Arc};
|
||||
|
||||
const ADAPTER: &str = include_str!("fixtures/target_adapter.py");
|
||||
|
||||
#[tokio::test]
|
||||
async fn stdio_adapter_through_target_maps_launch_breakpoints_and_frames() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let adapter = root.join("adapter.py");
|
||||
std::fs::write(&adapter, ADAPTER).unwrap();
|
||||
let wrapper = root.join("wrapper");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nprintf '%s\\n' \"$@\" > wrapper-argv\nexec \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let target = ide_core::remote_settings::PluginRunTarget {
|
||||
id: "fake".into(),
|
||||
name: "Fake".into(),
|
||||
program: "wrapper".into(),
|
||||
args: vec![],
|
||||
workdir_args: vec![],
|
||||
container_root: Some("/app".into()),
|
||||
commands: vec![],
|
||||
};
|
||||
// Keep the fixture adapter outside mapped argv; this is the container's executable.
|
||||
let run: ide_run::RunConfig = serde_json::from_value(serde_json::json!({"id":"adapter","name":"Adapter","kind":"shell","command":"/usr/bin/python3","args":["-u","-c",ADAPTER],"cwd":".","env":{},"detected":false,"isTest":false})).unwrap();
|
||||
let run = ide_remote::plugin_target::prepare(&root, &target, &wrapper, &run).unwrap();
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
manager
|
||||
.set_breakpoints(
|
||||
root.join("main.py"),
|
||||
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let config = LaunchConfig {
|
||||
name: "Target".into(),
|
||||
adapter: "debugpy".into(),
|
||||
request: "launch".into(),
|
||||
program: Some(root.join("main.py").to_string_lossy().into_owned()),
|
||||
args: vec![],
|
||||
cwd: Some(root.clone().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: None,
|
||||
};
|
||||
let sink: SharedSink = Arc::new(|_| {});
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
|
||||
external_prefix: "openidea-target://fake/0/".into(),
|
||||
};
|
||||
let id = manager
|
||||
.start_prepared(
|
||||
config,
|
||||
SessionOwner::new("test", sink),
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
spec.command = run.command;
|
||||
spec.args = run.args;
|
||||
spec.cwd = root;
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let frames = manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(
|
||||
frames[0].path.as_ref().unwrap().to_string_lossy(),
|
||||
tmp.path().canonicalize().unwrap().join("main.py").to_string_lossy()
|
||||
);
|
||||
assert_eq!(frames[0].line, 2);
|
||||
assert!(std::fs::read_to_string(tmp.path().join("wrapper-argv")).unwrap().starts_with("python3\n-u\n-c\n"));
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn host_adapter_accepts_connect_back_from_target_with_mapped_sources() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let port = ide_dap::adapter::free_port().unwrap();
|
||||
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
|
||||
manager
|
||||
.set_breakpoints(
|
||||
root.join("main.py"),
|
||||
vec![Breakpoint { line: 2, enabled: true, condition: None, hit_condition: None, log_message: None }],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let config = LaunchConfig {
|
||||
name: "Connect back".into(),
|
||||
adapter: "php-debug".into(),
|
||||
request: "launch".into(),
|
||||
program: None,
|
||||
args: vec![],
|
||||
cwd: Some(root.clone().into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(serde_json::json!({"port":port,"pathMappings":{"/app":"/app"}}).to_string()),
|
||||
};
|
||||
let sink: SharedSink = Arc::new(|_| {});
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&root.to_string_lossy(), "/app"),
|
||||
external_prefix: "openidea-target://fake/0/".into(),
|
||||
};
|
||||
let id = manager
|
||||
.start_prepared(
|
||||
config,
|
||||
SessionOwner::new("test", sink),
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
spec.command = "/usr/bin/python3".into();
|
||||
spec.args = vec!["-u".into(), "-c".into(), ADAPTER.into()];
|
||||
spec.env.push(("FAKE_CONNECT_BACK".into(), "1".into()));
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let code =
|
||||
format!("import socket;s=socket.create_connection(('127.0.0.1', {port}));s.sendall(b'/app/main.py');s.close()");
|
||||
let mut child = tokio::process::Command::new("/usr/bin/python3").args(["-c", &code]).spawn().unwrap();
|
||||
let frames = manager.stack_trace(id, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
|
||||
assert!(child.wait().await.unwrap().success());
|
||||
manager.stop(id).await.unwrap();
|
||||
}
|
||||
@@ -253,6 +253,32 @@ pub fn inspect(ep: &DockerEndpoint, object: DockerObject, id: &str) -> IdeResult
|
||||
}
|
||||
}
|
||||
|
||||
fn check_id(id: &str) -> IdeResult<&str> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.starts_with('-') { Err(IdeError::invalid("invalid Docker identifier")) } else { Ok(id) }
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'` of one image.
|
||||
pub fn image_history(ep: &DockerEndpoint, id: &str) -> IdeResult<Vec<DockerImageLayer>> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["image", "history", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
Ok(parse_image_history(&out))
|
||||
}
|
||||
|
||||
/// `docker top <id>` of a running container.
|
||||
pub fn container_top(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerProcessList> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["top", id], QUICK)?;
|
||||
Ok(parse_container_top(&out))
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}' <id>` of a running container.
|
||||
pub fn container_stats(ep: &DockerEndpoint, id: &str) -> IdeResult<DockerContainerStats> {
|
||||
let id = check_id(id)?;
|
||||
let out = run(ep, &["stats", "--no-stream", "--no-trunc", "--format", "{{json .}}", id], QUICK)?;
|
||||
parse_container_stats(&out).ok_or_else(|| IdeError::not_found("no container stats returned"))
|
||||
}
|
||||
|
||||
/// Streams a container's logs (`tail` last lines, then follows while `follow`)
|
||||
/// until it ends or `cancel` fires.
|
||||
pub fn logs(
|
||||
|
||||
+181
-19
@@ -41,6 +41,15 @@ pub struct DockerContainer {
|
||||
pub compose_working_dir: Option<String>,
|
||||
/// `com.docker.compose.project.config_files` label (comma-separated in docker).
|
||||
pub compose_files: Vec<String>,
|
||||
/// Mounted volume names or host paths reported by `docker ps` (`Mounts`).
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<String>,
|
||||
/// Connected network names reported by `docker ps` (`Networks`).
|
||||
#[serde(default)]
|
||||
pub networks: Vec<String>,
|
||||
/// User and tool labels assigned to the container.
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl DockerContainer {
|
||||
@@ -80,6 +89,10 @@ pub struct DockerVolume {
|
||||
pub driver: String,
|
||||
pub mountpoint: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub scope: String,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -90,6 +103,46 @@ pub struct DockerNetwork {
|
||||
pub driver: String,
|
||||
pub scope: String,
|
||||
pub compose_project: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: String,
|
||||
#[serde(default)]
|
||||
pub ipv6: bool,
|
||||
#[serde(default)]
|
||||
pub internal: bool,
|
||||
#[serde(default)]
|
||||
pub labels: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// One layer in `docker image history --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerImageLayer {
|
||||
pub id: String,
|
||||
pub created_by: String,
|
||||
pub size: String,
|
||||
pub created_since: String,
|
||||
pub created_at: String,
|
||||
pub comment: String,
|
||||
}
|
||||
|
||||
/// Process table returned by `docker top <container>`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerProcessList {
|
||||
pub titles: Vec<String>,
|
||||
pub processes: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Live resource metrics returned by `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DockerContainerStats {
|
||||
pub cpu_perc: String,
|
||||
pub mem_usage: String,
|
||||
pub mem_perc: String,
|
||||
pub net_io: String,
|
||||
pub block_io: String,
|
||||
pub pids: String,
|
||||
}
|
||||
|
||||
/// Everything the Services tree shows, fetched in one go.
|
||||
@@ -186,17 +239,20 @@ struct RawContainer {
|
||||
ports: String,
|
||||
created_at: String,
|
||||
labels: String,
|
||||
mounts: String,
|
||||
networks: String,
|
||||
}
|
||||
|
||||
fn split_csv(raw: &str) -> Vec<String> {
|
||||
raw.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
/// `docker ps -a --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
let mut out: Vec<DockerContainer> = lines::<RawContainer>(text)
|
||||
.map(|r| {
|
||||
let mut labels = parse_labels(&r.labels);
|
||||
let files = labels
|
||||
.remove(COMPOSE_FILES)
|
||||
.map(|f| f.split(',').map(str::trim).filter(|s| !s.is_empty()).map(str::to_owned).collect())
|
||||
.unwrap_or_default();
|
||||
let all_labels = parse_labels(&r.labels);
|
||||
let files = all_labels.get(COMPOSE_FILES).map(|f| split_csv(f)).unwrap_or_default();
|
||||
// Older docker versions derive `State` from nothing: fall back on the status text.
|
||||
let state = if r.state.is_empty() {
|
||||
if r.status.starts_with("Up") { "running".into() } else { "exited".into() }
|
||||
@@ -212,10 +268,13 @@ pub fn parse_containers(text: &str) -> Vec<DockerContainer> {
|
||||
status: r.status,
|
||||
ports: r.ports,
|
||||
created_at: r.created_at,
|
||||
compose_project: labels.remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
compose_service: labels.remove(COMPOSE_SERVICE).filter(|s| !s.is_empty()),
|
||||
compose_working_dir: labels.remove(COMPOSE_DIR).filter(|s| !s.is_empty()),
|
||||
compose_project: all_labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_service: all_labels.get(COMPOSE_SERVICE).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_working_dir: all_labels.get(COMPOSE_DIR).filter(|s| !s.is_empty()).cloned(),
|
||||
compose_files: files,
|
||||
mounts: split_csv(&r.mounts),
|
||||
networks: split_csv(&r.networks),
|
||||
labels: all_labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
@@ -258,17 +317,23 @@ struct RawVolume {
|
||||
name: String,
|
||||
driver: String,
|
||||
mountpoint: String,
|
||||
scope: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker volume ls --format '{{json .}}'`.
|
||||
pub fn parse_volumes(text: &str) -> Vec<DockerVolume> {
|
||||
let mut out: Vec<DockerVolume> = lines::<RawVolume>(text)
|
||||
.map(|r| DockerVolume {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerVolume {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
mountpoint: r.mountpoint,
|
||||
scope: r.scope,
|
||||
labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -283,24 +348,121 @@ struct RawNetwork {
|
||||
name: String,
|
||||
driver: String,
|
||||
scope: String,
|
||||
created_at: String,
|
||||
#[serde(rename = "IPv6")]
|
||||
ipv6: String,
|
||||
internal: String,
|
||||
labels: String,
|
||||
}
|
||||
|
||||
/// `docker network ls --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_networks(text: &str) -> Vec<DockerNetwork> {
|
||||
let mut out: Vec<DockerNetwork> = lines::<RawNetwork>(text)
|
||||
.map(|r| DockerNetwork {
|
||||
compose_project: parse_labels(&r.labels).remove(COMPOSE_PROJECT).filter(|s| !s.is_empty()),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
.map(|r| {
|
||||
let labels = parse_labels(&r.labels);
|
||||
DockerNetwork {
|
||||
compose_project: labels.get(COMPOSE_PROJECT).filter(|s| !s.is_empty()).cloned(),
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
driver: r.driver,
|
||||
scope: r.scope,
|
||||
created_at: r.created_at,
|
||||
ipv6: r.ipv6.eq_ignore_ascii_case("true"),
|
||||
internal: r.internal.eq_ignore_ascii_case("true"),
|
||||
labels,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawImageLayer {
|
||||
#[serde(rename = "ID")]
|
||||
id: String,
|
||||
created_by: String,
|
||||
size: String,
|
||||
created_since: String,
|
||||
created_at: String,
|
||||
comment: String,
|
||||
}
|
||||
|
||||
/// `docker image history --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_image_history(text: &str) -> Vec<DockerImageLayer> {
|
||||
lines::<RawImageLayer>(text)
|
||||
.map(|r| DockerImageLayer {
|
||||
id: r.id,
|
||||
created_by: r.created_by,
|
||||
size: r.size,
|
||||
created_since: r.created_since,
|
||||
created_at: r.created_at,
|
||||
comment: r.comment,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `docker top <container>` tabular output.
|
||||
pub fn parse_container_top(text: &str) -> DockerProcessList {
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
let Some(header) = lines.next() else {
|
||||
return DockerProcessList::default();
|
||||
};
|
||||
let titles: Vec<String> = header.split_whitespace().map(str::to_owned).collect();
|
||||
if titles.is_empty() {
|
||||
return DockerProcessList::default();
|
||||
}
|
||||
let cols = titles.len();
|
||||
let processes = lines
|
||||
.map(|line| {
|
||||
let mut row = Vec::with_capacity(cols);
|
||||
let mut rest = line.trim_start();
|
||||
for _ in 0..cols.saturating_sub(1) {
|
||||
match rest.split_once(char::is_whitespace) {
|
||||
Some((field, tail)) => {
|
||||
row.push(field.to_owned());
|
||||
rest = tail.trim_start();
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
if !rest.is_empty() || row.len() < cols {
|
||||
row.push(rest.to_owned());
|
||||
}
|
||||
row
|
||||
})
|
||||
.collect();
|
||||
DockerProcessList { titles, processes }
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(rename_all = "PascalCase", default)]
|
||||
struct RawStats {
|
||||
#[serde(rename = "CPUPerc")]
|
||||
cpu_perc: String,
|
||||
mem_usage: String,
|
||||
mem_perc: String,
|
||||
#[serde(rename = "NetIO")]
|
||||
net_io: String,
|
||||
#[serde(rename = "BlockIO")]
|
||||
block_io: String,
|
||||
#[serde(rename = "PIDs")]
|
||||
pids: String,
|
||||
}
|
||||
|
||||
/// `docker stats --no-stream --no-trunc --format '{{json .}}'`.
|
||||
pub fn parse_container_stats(text: &str) -> Option<DockerContainerStats> {
|
||||
lines::<RawStats>(text).next().map(|r| DockerContainerStats {
|
||||
cpu_perc: r.cpu_perc,
|
||||
mem_usage: r.mem_usage,
|
||||
mem_perc: r.mem_perc,
|
||||
net_io: r.net_io,
|
||||
block_io: r.block_io,
|
||||
pids: r.pids,
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct RawVersion {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
//! Parsing of recorded `docker … --format '{{json .}}'` output.
|
||||
|
||||
use ide_docker::{parse_containers, parse_images, parse_networks, parse_volumes};
|
||||
use ide_docker::{
|
||||
parse_container_stats, parse_container_top, parse_containers, parse_image_history, parse_images, parse_networks,
|
||||
parse_volumes,
|
||||
};
|
||||
|
||||
const PS: &str = include_str!("fixtures/ps.jsonl");
|
||||
const IMAGES: &str = include_str!("fixtures/images.jsonl");
|
||||
@@ -22,11 +25,16 @@ fn containers_with_compose_labels() {
|
||||
assert_eq!(db.compose_working_dir.as_deref(), Some("/work/shop"));
|
||||
assert_eq!(db.compose_files, ["/work/shop/compose.yaml", "/work/shop/compose.override.yaml"]);
|
||||
assert_eq!(db.ports, "0.0.0.0:5432->5432/tcp");
|
||||
assert_eq!(db.mounts, ["shop_pgdata"]);
|
||||
assert_eq!(db.networks, ["shop_default"]);
|
||||
assert_eq!(db.labels.get("com.docker.compose.project").map(String::as_str), Some("shop"));
|
||||
|
||||
let web = &c[1];
|
||||
assert_eq!(web.name, "web", "leading slash of legacy names is dropped");
|
||||
assert!(!web.running());
|
||||
assert_eq!(web.compose_project, None);
|
||||
assert!(web.mounts.is_empty());
|
||||
assert_eq!(web.networks, ["bridge"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -44,9 +52,44 @@ fn volumes_and_networks() {
|
||||
assert_eq!(v.iter().map(|v| v.name.as_str()).collect::<Vec<_>>(), ["0a1b", "shop_pgdata"]);
|
||||
assert_eq!(v[1].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(v[1].mountpoint, "/var/lib/docker/volumes/shop_pgdata/_data");
|
||||
assert_eq!(v[1].scope, "local");
|
||||
assert_eq!(v[1].labels.get("com.docker.compose.volume").map(String::as_str), Some("pgdata"));
|
||||
|
||||
let n = parse_networks(NETWORKS);
|
||||
assert_eq!(n.iter().map(|n| n.name.as_str()).collect::<Vec<_>>(), ["bridge", "host", "shop_default"]);
|
||||
assert_eq!(n[2].compose_project.as_deref(), Some("shop"));
|
||||
assert_eq!(n[1].driver, "host");
|
||||
assert!(!n[2].ipv6);
|
||||
assert!(!n[2].internal);
|
||||
assert_eq!(n[2].labels.get("com.docker.compose.network").map(String::as_str), Some("default"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_history_top_and_stats() {
|
||||
let history = [
|
||||
r#"{"Comment":"buildkit.dockerfile.v0","CreatedAt":"2026-10-01 09:00:00 +0000 UTC","CreatedBy":"CMD [\"postgres\"]","CreatedSince":"5 weeks ago","ID":"sha256:1234567890ab","Size":"0B"}"#,
|
||||
r#"{"Comment":"","CreatedAt":"2026-10-01 08:59:00 +0000 UTC","CreatedBy":"/bin/sh -c #(nop) COPY file:abc in /usr/local/bin/ ","CreatedSince":"5 weeks ago","ID":"<missing>","Size":"42.5MB"}"#,
|
||||
]
|
||||
.join("\n");
|
||||
let layers = parse_image_history(&history);
|
||||
assert_eq!(layers.len(), 2);
|
||||
assert_eq!(layers[0].created_by, "CMD [\"postgres\"]");
|
||||
assert_eq!(layers[1].size, "42.5MB");
|
||||
|
||||
let top = "UID PID PPID C STIME TTY TIME CMD\nroot 1234 1200 0 09:12 ? 00:00:01 postgres -c config_file=/etc/postgresql.conf\n";
|
||||
let parsed_top = parse_container_top(top);
|
||||
assert_eq!(parsed_top.titles, ["UID", "PID", "PPID", "C", "STIME", "TTY", "TIME", "CMD"]);
|
||||
assert_eq!(parsed_top.processes.len(), 1);
|
||||
assert_eq!(parsed_top.processes[0][0], "root");
|
||||
assert_eq!(parsed_top.processes[0][1], "1234");
|
||||
assert_eq!(parsed_top.processes[0][7], "postgres -c config_file=/etc/postgresql.conf");
|
||||
|
||||
let stats_line = r#"{"BlockIO":"1.2MB / 0B","CPUPerc":"1.25%","Container":"shop-db-1","ID":"4f2b8c1d9e0a","MemPerc":"2.50%","MemUsage":"50MiB / 2GiB","Name":"shop-db-1","NetIO":"12kB / 8kB","PIDs":"14"}"#;
|
||||
let stats = parse_container_stats(stats_line).expect("stats parsed");
|
||||
assert_eq!(stats.cpu_perc, "1.25%");
|
||||
assert_eq!(stats.mem_usage, "50MiB / 2GiB");
|
||||
assert_eq!(stats.mem_perc, "2.50%");
|
||||
assert_eq!(stats.net_io, "12kB / 8kB");
|
||||
assert_eq!(stats.block_io, "1.2MB / 0B");
|
||||
assert_eq!(stats.pids, "14");
|
||||
}
|
||||
@@ -10,6 +10,7 @@ name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
percent-encoding = "2"
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
|
||||
@@ -56,7 +56,7 @@ pub struct HostConfig {
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
plugin_host: Arc<RwLock<std::sync::Weak<plugins::PluginHost>>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
pub trust: trust::TrustStore,
|
||||
@@ -125,7 +125,7 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: RwLock::default(),
|
||||
plugin_host: Arc::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod output;
|
||||
mod target;
|
||||
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
@@ -9,6 +10,15 @@ use std::path::PathBuf;
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let target_id = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
||||
.and_then(|v| v.get("__runTarget").and_then(|v| v.as_str()).map(str::to_owned));
|
||||
if let Some(id) = target_id {
|
||||
let owner = SessionOwner::new(host.owner_window(window), host.window_sink(window));
|
||||
return target::start(host, window, config, owner, id).await;
|
||||
}
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -351,6 +361,14 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// A loopback port that is free right now, for a debug agent the launch line starts
|
||||
/// (`address=127.0.0.1:<port>`) — used when its "Listening for transport" line never reaches
|
||||
/// the console (a Surefire fork holds it until the tests run, which `suspend=y` prevents).
|
||||
pub async fn debug_free_port(host: &Host, window: &str) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
ide_dap::adapter::free_port().map_err(ide_core::IdeError::from)
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
@@ -492,6 +510,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_free_port", |c| async move { debug_free_port(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
use crate::Host;
|
||||
use ide_core::path_mapping::PathMapping;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_dap::target::TargetMapping;
|
||||
use ide_dap::{LaunchConfig, SessionOwner};
|
||||
use ide_language::ProgramPin;
|
||||
use serde_json::json;
|
||||
|
||||
pub(super) async fn start(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: LaunchConfig,
|
||||
owner: SessionOwner,
|
||||
id: String,
|
||||
) -> IdeResult<ide_core::DebugSessionId> {
|
||||
let project = host.project(window)?;
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
let registration = plugins
|
||||
.run_targets(&project.root)
|
||||
.into_iter()
|
||||
.find(|t| t.id == id)
|
||||
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
let container = registration
|
||||
.target
|
||||
.container_root
|
||||
.ok_or_else(|| IdeError::invalid("Debugging a plugin target requires a container root"))?;
|
||||
let dbg = ide_dap::adapter::find_config(&host.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::invalid("unknown debug adapter"))?;
|
||||
let placement = dbg.target_placement.as_deref().unwrap_or("");
|
||||
if !matches!(placement, "connect-back" | "adapter-in-target")
|
||||
|| (placement == "adapter-in-target" && dbg.transport != "stdio")
|
||||
{
|
||||
return Err(IdeError::invalid("This debugger does not support plugin run targets"));
|
||||
}
|
||||
let mapping = TargetMapping {
|
||||
paths: PathMapping::new(&project.root.to_string_lossy(), &container),
|
||||
external_prefix: format!(
|
||||
"openidea-target://{}/0/",
|
||||
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC)
|
||||
),
|
||||
};
|
||||
let mut extra: serde_json::Value = config
|
||||
.extra_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or(json!({}));
|
||||
extra.as_object_mut().ok_or_else(|| IdeError::invalid("invalid debug launch arguments"))?.remove("__runTarget");
|
||||
// DAP paths already use target coordinates; adapter-native mappings stay identity.
|
||||
if placement == "connect-back" {
|
||||
config.program = None;
|
||||
config.args.clear();
|
||||
for key in ["program", "runtimeExecutable", "runtimeArgs"] {
|
||||
extra.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
if extra.get("port").and_then(|v| v.as_u64()).unwrap_or(0) == 0 {
|
||||
extra["port"] = json!(9003);
|
||||
}
|
||||
extra["hostname"] = json!("0.0.0.0");
|
||||
extra["pathMappings"] = json!({container.clone(): container.clone()});
|
||||
} else {
|
||||
for key in ["debugLauncherPython", "pythonPath"] {
|
||||
extra.as_object_mut().unwrap().remove(key);
|
||||
}
|
||||
extra["console"] = json!("internalConsole");
|
||||
extra["python"] = json!(dbg.command);
|
||||
extra["pathMappings"] = json!([{"localRoot":container,"remoteRoot":container}]);
|
||||
}
|
||||
config.extra_json = Some(extra.to_string());
|
||||
let wrapped = if placement == "adapter-in-target" {
|
||||
let run = serde_json::from_value(json!({"id":"debug-adapter","name":"Debug adapter","kind":"shell","command":dbg.command,"args":dbg.args,"cwd":config.cwd,"env":config.env,"detected":false,"isTest":false})).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let root = project.root.clone();
|
||||
Some(host.blocking(move || plugins.prepare_run_target(&root, &id, &run)).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let root = project.root.clone();
|
||||
let settings = project.settings.effective();
|
||||
host.debugger
|
||||
.start_prepared(
|
||||
config,
|
||||
owner,
|
||||
|_| None,
|
||||
move |spec, _| {
|
||||
if let Some(run) = wrapped {
|
||||
spec.command = run.command;
|
||||
spec.args = run.args;
|
||||
spec.cwd = run.cwd.map(Into::into).unwrap_or(root);
|
||||
spec.env = run.env.into_iter().collect();
|
||||
spec.pinned = Some(ProgramPin::approve(&spec.command)?);
|
||||
} else if let Some(command) = ide_sdk::apply::tool_command(&root, &settings, &spec.command) {
|
||||
spec.command = command.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.target = Some(mapping);
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
+146
-23
@@ -4,11 +4,39 @@ use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::plugins::{ProviderKind, editor_providers_for as editor_providers};
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_core::{DocId, IdeError, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
use std::path::Path;
|
||||
|
||||
/// Select an embedded server only for point requests; formatting and rename keep the host.
|
||||
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
lsp.refresh_injections(doc).await?;
|
||||
Ok(lsp.injected_document(doc, pos))
|
||||
}
|
||||
|
||||
async fn begin_position_request(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
|
||||
let cancel = host.begin_request(request);
|
||||
let target = tokio::select! {
|
||||
result = position_document(host, window, doc, pos) => result,
|
||||
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
|
||||
};
|
||||
match target {
|
||||
Ok(target) => Ok((target, cancel)),
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
doc: DocId,
|
||||
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
|
||||
host.end_request(request);
|
||||
// Plugin providers (`[[contributes.providers]]`) add theirs.
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
|
||||
@@ -238,14 +266,14 @@ pub async fn lsp_hover(
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
let result = lsp.hover(target, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
return result;
|
||||
};
|
||||
let (result, hovers) =
|
||||
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
|
||||
host.end_request(request);
|
||||
let hovers = hovers.unwrap_or_default();
|
||||
if hovers.is_empty() {
|
||||
@@ -260,7 +288,8 @@ pub async fn lsp_hover(
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.signature_help(target, pos).await;
|
||||
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
|
||||
return server;
|
||||
}
|
||||
@@ -278,7 +307,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if kind == GotoKind::Definition
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
@@ -289,7 +320,7 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
return Ok(found.targets);
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
|
||||
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
|
||||
let k =
|
||||
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
|
||||
@@ -424,8 +455,46 @@ pub async fn lsp_implementations(
|
||||
positions: Vec<Pos>,
|
||||
) -> IdeResult<Vec<Vec<Location>>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
let (_, cancel) =
|
||||
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
|
||||
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
|
||||
for (index, pos) in positions.iter().copied().enumerate() {
|
||||
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
|
||||
}
|
||||
if groups.is_empty() {
|
||||
groups.insert(doc, Vec::new());
|
||||
}
|
||||
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
|
||||
let (lsp, cancel) = (lsp.clone(), cancel.clone());
|
||||
async move {
|
||||
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
|
||||
(entries, lsp.implementations(target, &positions, cancel).await)
|
||||
}
|
||||
}))
|
||||
.await;
|
||||
let mut out = vec![Vec::new(); positions.len()];
|
||||
let mut error = None;
|
||||
let mut succeeded = false;
|
||||
for (entries, result) in results {
|
||||
match result {
|
||||
Ok(found) => {
|
||||
succeeded = true;
|
||||
for ((index, _), locations) in entries.into_iter().zip(found) {
|
||||
out[index] = locations;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error.get_or_insert(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = if cancel.is_cancelled() {
|
||||
Err(ide_core::IdeError::cancelled())
|
||||
} else if succeeded {
|
||||
Ok(out)
|
||||
} else {
|
||||
Err(error.expect("at least one request"))
|
||||
};
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let count = positions.len();
|
||||
@@ -456,9 +525,9 @@ pub async fn lsp_goto_implementation(
|
||||
on_partial: super::Channel<Vec<Location>>,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = lsp
|
||||
.goto_implementation(doc, pos, cancel, &|locs| {
|
||||
.goto_implementation(target, pos, cancel, &|locs| {
|
||||
let _ = on_partial.send(locs);
|
||||
})
|
||||
.await;
|
||||
@@ -476,6 +545,42 @@ pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> Ide
|
||||
if uri.starts_with("git-revision:") {
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
|
||||
}
|
||||
if uri.starts_with("openidea-target:") && uri.contains("/0/") {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
|
||||
for registration in plugins.run_targets(&project.root) {
|
||||
let prefix = format!(
|
||||
"openidea-target://{}/0/",
|
||||
percent_encoding::utf8_percent_encode(®istration.id, percent_encoding::NON_ALPHANUMERIC)
|
||||
);
|
||||
if let Some(suffix) = uri.strip_prefix(&prefix) {
|
||||
let path = ide_core::path_mapping::file_path(&format!("file:///{suffix}"))
|
||||
.filter(|p| p.starts_with('/') && !p.split('/').any(|s| s == ".." || s == "."))
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let container_root = registration
|
||||
.target
|
||||
.container_root
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let target = ide_language::ServerTarget {
|
||||
id: registration.id,
|
||||
origin: None,
|
||||
project_root: project.root.clone(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&project.root.to_string_lossy(), &container_root),
|
||||
container_root,
|
||||
};
|
||||
let title = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let language_id = host.languages.detect(std::path::Path::new(&path), None);
|
||||
let text = host.blocking(move || super::lsp_target::read(&plugins, &target, &path)).await?;
|
||||
return Ok(VirtualDocument { uri, text, title, language_id, description: None, decompiled: false });
|
||||
}
|
||||
}
|
||||
return Err(IdeError::unavailable("plugin run target is no longer available"));
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -487,8 +592,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
|
||||
/// are not searched; with it, those found in the sources arrive on `on_partial` before
|
||||
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
|
||||
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
|
||||
/// the slower search for the rest, and the result has them all. Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -505,9 +610,9 @@ pub async fn lsp_references(
|
||||
) -> IdeResult<References> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
|
||||
let result = async {
|
||||
let symbol = indexed_symbol(host, window, doc, pos).await?;
|
||||
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
|
||||
if let Some(symbol) = &symbol {
|
||||
let project = project.clone();
|
||||
let snapshot = symbol.clone();
|
||||
@@ -517,7 +622,7 @@ pub async fn lsp_references(
|
||||
}
|
||||
let result = lsp
|
||||
.references_streaming(
|
||||
doc,
|
||||
target,
|
||||
pos,
|
||||
include_declaration,
|
||||
project_wide,
|
||||
@@ -643,7 +748,8 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
|
||||
return server;
|
||||
};
|
||||
@@ -674,7 +780,9 @@ pub async fn lsp_code_actions(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let result = project.lsp.code_actions(doc, range, only.clone()).await;
|
||||
let target = position_document(host, window, doc, range.start).await?;
|
||||
let range = project.lsp.injected_request_range(target, range);
|
||||
let result = project.lsp.code_actions(target, range, only.clone()).await;
|
||||
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
|
||||
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
|
||||
None => Vec::new(),
|
||||
@@ -811,8 +919,9 @@ pub async fn lsp_code_action_query(
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
|
||||
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
|
||||
let range = project.lsp.injected_request_range(target, range);
|
||||
let result = project.lsp.code_action_query(target, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
let mut result = result?;
|
||||
result.actions = prepare_actions(project, doc, version, result.actions).await?;
|
||||
@@ -1433,7 +1542,9 @@ pub struct PluginLanguageVisuals {
|
||||
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
|
||||
}
|
||||
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let generation = project.lsp.injection_generation();
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
@@ -1450,6 +1561,18 @@ pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> I
|
||||
out.colors = plugins.language_colors(&d);
|
||||
out.gutter_actions = plugins.language_gutter_actions(&d);
|
||||
}
|
||||
project.lsp.set_injections_at(
|
||||
doc,
|
||||
d.version as u32,
|
||||
out.injections
|
||||
.iter()
|
||||
.map(|i| ide_lsp::InjectedRange {
|
||||
range: i.range,
|
||||
language: ide_core::LanguageId::new(i.language.clone()),
|
||||
})
|
||||
.collect(),
|
||||
generation,
|
||||
);
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Project language-server targets, shared by desktop and openidea-server.
|
||||
use crate::plugins::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{LanguageServerConfig, ServerTarget};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn configure(
|
||||
plugins: &PluginHost,
|
||||
root: &Path,
|
||||
id: &str,
|
||||
cfg: &mut LanguageServerConfig,
|
||||
) -> IdeResult<bool> {
|
||||
let Some(registration) = plugins.run_targets(root).into_iter().find(|t| t.id == id) else { return Ok(false) };
|
||||
let container_root = registration.target.container_root.clone().unwrap_or_default();
|
||||
cfg.target = Some(ServerTarget {
|
||||
id: id.into(),
|
||||
origin: None,
|
||||
project_root: root.into(),
|
||||
container_root: container_root.clone(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), &container_root),
|
||||
});
|
||||
if container_root.is_empty() {
|
||||
return Err(IdeError::invalid("language server targets require a container root"));
|
||||
}
|
||||
let run = command(&cfg.command, cfg.args.clone());
|
||||
let wrapped = plugins.prepare_run_target(root, id, &run)?;
|
||||
cfg.command = wrapped.command;
|
||||
cfg.args = wrapped.args;
|
||||
cfg.connect = None;
|
||||
cfg.pinned = Some(ide_language::ProgramPin::approve(&cfg.command)?);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn command(command: &str, args: Vec<String>) -> ide_run::RunConfig {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"id":"language-server", "name":"Language server", "kind":"shell",
|
||||
"command":command, "args":args, "cwd":".", "env":{}, "detected":false, "isTest":false
|
||||
}))
|
||||
.expect("valid internal run configuration")
|
||||
}
|
||||
|
||||
pub(crate) fn read(plugins: &PluginHost, target: &ServerTarget, path: &str) -> IdeResult<String> {
|
||||
let registration = plugins
|
||||
.run_targets(&target.project_root)
|
||||
.into_iter()
|
||||
.find(|t| t.id == target.id)
|
||||
.ok_or_else(|| IdeError::unavailable("plugin run target is no longer available"))?;
|
||||
if registration.target.container_root.as_deref() != Some(&target.container_root) {
|
||||
return Err(IdeError::unavailable("plugin run target changed; navigate again"));
|
||||
}
|
||||
let mut wrapped = plugins.prepare_run_target(&target.project_root, &target.id, &command("cat", Vec::new()))?;
|
||||
wrapped.args.extend(["--".into(), path.into()]);
|
||||
capture(&wrapped, &target.project_root)
|
||||
}
|
||||
|
||||
/// Bounds both output and time, and always kills/reaps the wrapper on failure.
|
||||
fn capture(run: &ide_run::RunConfig, root: &Path) -> IdeResult<String> {
|
||||
use std::io::Read;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
const LIMIT: u64 = 4 * 1024 * 1024;
|
||||
let mut cmd = Command::new(&run.command);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
ide_core::process::own_group(&mut cmd);
|
||||
let mut child = cmd
|
||||
.args(&run.args)
|
||||
.current_dir(root)
|
||||
.envs(ide_core::process::shell_env())
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let stdout = child.stdout.take().expect("piped stdout");
|
||||
let stderr = child.stderr.take().expect("piped stderr");
|
||||
let overflow = Arc::new(AtomicBool::new(false));
|
||||
let large = overflow.clone();
|
||||
let output = std::thread::spawn(move || {
|
||||
let mut bytes = Vec::new();
|
||||
let result = stdout.take(LIMIT + 1).read_to_end(&mut bytes);
|
||||
large.store(bytes.len() as u64 > LIMIT, Ordering::Release);
|
||||
result.map(|_| bytes)
|
||||
});
|
||||
let errors = std::thread::spawn(move || {
|
||||
let mut stderr = stderr;
|
||||
let mut bytes = Vec::new();
|
||||
let _ = (&mut stderr).take(65536).read_to_end(&mut bytes);
|
||||
let _ = std::io::copy(&mut stderr, &mut std::io::sink());
|
||||
bytes
|
||||
});
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
let status = loop {
|
||||
if output.is_finished()
|
||||
&& errors.is_finished()
|
||||
&& let Ok(Some(status)) = child.try_wait()
|
||||
{
|
||||
break Ok(status);
|
||||
}
|
||||
match (std::time::Instant::now() < deadline, overflow.load(Ordering::Acquire)) {
|
||||
(true, false) => std::thread::sleep(std::time::Duration::from_millis(10)),
|
||||
_ => {
|
||||
ide_core::process::kill_tree_of(child.id());
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
break Err(IdeError::unavailable("container document exceeds the size or time limit"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let bytes = output.join().map_err(|_| IdeError::unavailable("container document reader failed"))??;
|
||||
let stderr = errors.join().unwrap_or_default();
|
||||
if !status?.success() {
|
||||
return Err(IdeError::unavailable(format!("container document: {}", String::from_utf8_lossy(&stderr))));
|
||||
}
|
||||
if bytes.len() as u64 > LIMIT {
|
||||
return Err(IdeError::invalid("container document exceeds 4 MiB"));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| IdeError::invalid("container document is not UTF-8 text"))
|
||||
}
|
||||
@@ -98,3 +98,5 @@ impl Host {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod lsp_target;
|
||||
@@ -181,7 +181,7 @@ pub fn set_settings(
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
let toolchain = ["languages", "tools", "languageServers", "languageServerTargets", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
// Code style only reaches servers as settings (e.g. jdtls' formatter profile); builds don't depend on it.
|
||||
|
||||
@@ -218,12 +218,13 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?,
|
||||
)?;
|
||||
let base = ide_run::tomcat::effective_base_dir(&project.root, &project.cache_dir, &config)?;
|
||||
// "Build before launch" is skipped when the built WAR is already newer than the
|
||||
// module's sources, so a repeated run does not rebuild (IDEA's up-to-date check).
|
||||
if ide_run::tomcat::artifact_is_current(&project.root, &base, &config) {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut plan = ide_build::web::build(&project.root, &model, id, &base)?;
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
|
||||
@@ -301,7 +301,7 @@ impl PluginHost {
|
||||
Ok(Some((root, owner.to_owned(), id.to_owned())))
|
||||
}
|
||||
|
||||
/// Derives replacement argv under the parser's soft/hard call budgets.
|
||||
/// Derives replacement argv for a user action, under the command budget.
|
||||
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
if tests.is_empty()
|
||||
|| tests.len() > 10000
|
||||
@@ -317,9 +317,10 @@ impl PluginHost {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
|
||||
let hard = self.limits.edit_hard;
|
||||
// A user action, not the edit path: allow the command budget (a cold instance may be compiling).
|
||||
let timeout = self.limits.timeout;
|
||||
let started = Instant::now();
|
||||
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
|
||||
let result = actor.call(Some(timeout), move |rt| rt.select_tests(id, tests, invocation, timeout));
|
||||
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
|
||||
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
|
||||
//! Embedded documents preserve the host's UTF-16 coordinates.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::Injection};
|
||||
@@ -104,6 +104,38 @@ pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], languag
|
||||
text
|
||||
}
|
||||
|
||||
/// Shared by local and remote projects; all plugin work runs on the host.
|
||||
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
|
||||
parking_lot::Mutex::new(Vec::new());
|
||||
|
||||
pub(super) fn providers_changed() {
|
||||
let projects = {
|
||||
let mut projects = LSP_PROJECTS.lock();
|
||||
projects.retain(|p| p.strong_count() > 0);
|
||||
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
|
||||
};
|
||||
for project in projects {
|
||||
project.injection_providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
|
||||
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
|
||||
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
|
||||
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
|
||||
plugins
|
||||
.provide_injections(&ProviderDocument {
|
||||
path: path.to_path_buf(),
|
||||
language: language.0.clone(),
|
||||
version: version.into(),
|
||||
text: text.to_owned(),
|
||||
})
|
||||
.into_iter()
|
||||
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
|
||||
.collect()
|
||||
}));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
|
||||
@@ -76,16 +76,29 @@ pub struct Providers {
|
||||
|
||||
impl Providers {
|
||||
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
|
||||
let injections = decl.kinds.contains(&ProviderKind::Injections);
|
||||
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
|
||||
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
|
||||
if injections {
|
||||
self.injections_changed();
|
||||
}
|
||||
let weak = Arc::downgrade(self);
|
||||
Disposable::new(move || {
|
||||
if let Some(this) = weak.upgrade() {
|
||||
this.items.write().retain(|(s, ..)| *s != seq);
|
||||
if injections {
|
||||
this.injections_changed();
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn injections_changed(self: &Arc<Self>) {
|
||||
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
|
||||
super::injections::providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugins providing `kind` for `language`, in declaration order.
|
||||
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
|
||||
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
|
||||
@@ -260,6 +273,7 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
|
||||
/// definition and inline completion (`None` removes them).
|
||||
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
|
||||
super::injections::providers_changed();
|
||||
}
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
|
||||
@@ -21,6 +21,15 @@ fn selector(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-target:{}:{plugin}{id}", plugin.len())
|
||||
}
|
||||
impl Shared {
|
||||
fn targets_changed(&self) {
|
||||
if let Some(host) = self.workspace().and_then(|w| w.database_host()) {
|
||||
for project in host.projects() {
|
||||
project.lsp.reconfigure_soon();
|
||||
}
|
||||
}
|
||||
self.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
}
|
||||
|
||||
fn target_answer_file(&self, root: &Path, id: &str) -> PathBuf {
|
||||
let key = format!("{}\0{id}", root.display());
|
||||
self.data_dir.join(".run-target-answers").join(blake3::hash(key.as_bytes()).to_hex().as_str())
|
||||
@@ -31,7 +40,7 @@ impl Shared {
|
||||
targets.retain(|(r, _), t| !(plugin.is_none_or(|p| p == t.plugin_id) && root.is_none_or(|path| path == r)));
|
||||
if before != targets.len() {
|
||||
drop(targets);
|
||||
self.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
self.targets_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,12 +79,12 @@ impl PluginApi {
|
||||
);
|
||||
drop(targets);
|
||||
drop(blocked);
|
||||
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
self.shared.targets_changed();
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn unregister_target(&self, root: &Path, id: &str) {
|
||||
if self.shared.run_targets.lock().remove(&(root.into(), selector(self.pkg.id(), id))).is_some() {
|
||||
self.shared.sink.emit(None, PluginEvent::RunTargetsChanged);
|
||||
self.shared.targets_changed();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,6 +102,9 @@ impl PluginHost {
|
||||
id: &str,
|
||||
config: &ide_run::RunConfig,
|
||||
) -> IdeResult<ide_run::RunConfig> {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return Err(IdeError::new(ide_core::error::ErrorKind::PermissionDenied, "project is not trusted"));
|
||||
}
|
||||
let target = self
|
||||
.shared
|
||||
.run_targets
|
||||
|
||||
@@ -133,9 +133,45 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
crate::plugins::injections::configure_lsp(&lsp);
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let plugins = self.plugin_host.clone();
|
||||
let reader_plugins = plugins.clone();
|
||||
lsp.set_target_hooks(
|
||||
Box::new(move |language, cfg| {
|
||||
let effective = settings.effective();
|
||||
let Some(selection) =
|
||||
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(plugins) = plugins.read().upgrade() else { return false };
|
||||
// A withdrawn target returns the server to the host. An invalid active
|
||||
// target stays visibly failed; it must never silently fall back.
|
||||
if !plugins.run_targets(&root).iter().any(|t| t.id == selection.target) {
|
||||
return false;
|
||||
}
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
|
||||
if let Err(e) = crate::ops::lsp_target::configure(&plugins, &root, &selection.target, cfg) {
|
||||
cfg.launch_error = Some(e.to_string());
|
||||
}
|
||||
true
|
||||
}),
|
||||
Box::new(move |target, path| {
|
||||
let plugins = reader_plugins
|
||||
.read()
|
||||
.upgrade()
|
||||
.ok_or_else(|| IdeError::unavailable("plugin host is unavailable"))?;
|
||||
crate::ops::lsp_target::read(&plugins, target, path)
|
||||
}),
|
||||
);
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
@@ -272,7 +308,23 @@ impl Host {
|
||||
{
|
||||
let settings = settings.clone();
|
||||
lsp.set_servers_hook(Box::new(move |language, defined| {
|
||||
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
|
||||
let effective = settings.effective();
|
||||
let mut servers = ide_sdk::apply::language_servers(language.as_str(), defined, &effective);
|
||||
if let Some(selection) =
|
||||
effective.language_server_targets.get(language.as_str()).filter(|s| !s.target.is_empty())
|
||||
{
|
||||
let chosen = if selection.server.is_empty() {
|
||||
servers.first().map(|s| s.name.clone())
|
||||
} else {
|
||||
Some(selection.server.clone())
|
||||
};
|
||||
servers.retain(|s| Some(&s.name) == chosen.as_ref());
|
||||
for server in &mut servers {
|
||||
server.fallback = false;
|
||||
server.target_key = Some(selection.target.clone());
|
||||
}
|
||||
}
|
||||
servers
|
||||
}));
|
||||
}
|
||||
{
|
||||
@@ -1017,12 +1069,13 @@ impl Project {
|
||||
let Some(root) = root else {
|
||||
return ide_lsp::CandidateFiles::default();
|
||||
};
|
||||
let query = ide_search::WordQuery {
|
||||
word: q.word.clone(),
|
||||
dir: q.root.clone(),
|
||||
exclude: self.search_excludes(),
|
||||
limit: q.limit,
|
||||
};
|
||||
let mut exclude = self.search_excludes();
|
||||
// Directories the server analyses fully: their files are never read.
|
||||
exclude.extend(q.skip.iter().filter_map(|dir| {
|
||||
let rel = dir.strip_prefix(&root).ok()?.to_str()?.replace('\\', "/");
|
||||
(!rel.is_empty() && !rel.contains(['*', '?', '[', ']', '{', '}', '!', ','])).then(|| format!("{rel}/**"))
|
||||
}));
|
||||
let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude, limit: q.limit };
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
|
||||
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
|
||||
|
||||
@@ -93,6 +93,9 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
|
||||
let p = plugins(tmp.path());
|
||||
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
set_editor_providers(Some(&p));
|
||||
let generation = host.project("main").unwrap().lsp.injection_generation();
|
||||
drop(plugins(&tmp.path().join("unrelated")));
|
||||
assert_eq!(host.project("main").unwrap().lsp.injection_generation(), generation);
|
||||
let path = root.join("a.txt");
|
||||
std::fs::write(&path, "value\nnext\nend").unwrap();
|
||||
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
@@ -201,6 +204,7 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
|
||||
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
|
||||
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
|
||||
set_editor_providers(Some(&p));
|
||||
injected_lsp(&host, &p, &root, &peer).await;
|
||||
p.set_enabled(ID, false).unwrap();
|
||||
assert!(ops::lsp::plugin_formatters().is_empty());
|
||||
set_editor_providers(None);
|
||||
@@ -232,3 +236,131 @@ fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
|
||||
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
|
||||
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
|
||||
}
|
||||
|
||||
// Same TCP fake-server pattern as plugin_refactoring; the real Wasm export supplies the range.
|
||||
async fn injected_lsp(host: &Host, plugins: &PluginHost, root: &Path, remote: &ide_jsonrpc::Peer) {
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
#[derive(Default)]
|
||||
struct Fake {
|
||||
docs: Mutex<std::collections::HashMap<String, String>>,
|
||||
requests: Mutex<Vec<(String, Value)>>,
|
||||
}
|
||||
impl Handler for Fake {
|
||||
fn on_notification(&self, method: &str, p: Value) {
|
||||
let uri = p["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
|
||||
match method {
|
||||
"textDocument/didOpen" => {
|
||||
self.docs.lock().insert(uri, p["textDocument"]["text"].as_str().unwrap().into());
|
||||
}
|
||||
"textDocument/didChange" => {
|
||||
self.docs.lock().insert(uri, p["contentChanges"][0]["text"].as_str().unwrap().into());
|
||||
}
|
||||
"textDocument/didClose" => {
|
||||
self.docs.lock().remove(&uri);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn on_request(&self, method: &str, p: Value) -> Result<Value, (i64, String)> {
|
||||
self.requests.lock().push((method.to_owned(), p.clone()));
|
||||
Ok(match method {
|
||||
"initialize" => {
|
||||
json!({"capabilities": {"textDocumentSync": {"openClose":true,"change":1}, "completionProvider":{"resolveProvider":true}, "hoverProvider":true, "definitionProvider":true}})
|
||||
}
|
||||
"textDocument/completion" => json!([{"label":"embedded-server", "data":{"identity":7}}]),
|
||||
"completionItem/resolve" => {
|
||||
assert_eq!(p["data"], json!({"identity":7}));
|
||||
json!({"label":"embedded-server", "detail":"resolved"})
|
||||
}
|
||||
"textDocument/hover" => {
|
||||
json!({"contents":"embedded hover", "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
|
||||
}
|
||||
"textDocument/definition" => {
|
||||
json!({"uri":p["textDocument"]["uri"], "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
|
||||
}
|
||||
_ => Value::Null,
|
||||
})
|
||||
}
|
||||
}
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let fake = Arc::new(Fake::default());
|
||||
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
|
||||
let (accepted, state) = (peers.clone(), fake.clone());
|
||||
let task = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let (r, w) = stream.into_split();
|
||||
accepted.lock().push(Peer::connect(r, w, state.clone()));
|
||||
}
|
||||
});
|
||||
let cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str(&format!("name='injected-sql'\ncommand='no-process-required'\nconnect='127.0.0.1:{port}'"))
|
||||
.unwrap();
|
||||
let project = host.project("main").unwrap();
|
||||
project.lsp.set_servers_hook(Box::new(
|
||||
move |language, _| if language.as_str() == "sql" { vec![cfg.clone()] } else { Vec::new() },
|
||||
));
|
||||
let path = root.join("injection.txt");
|
||||
std::fs::write(&path, "😀[[sql]] tail").unwrap();
|
||||
let doc = ops::doc::open(host, "main", path.clone()).await.unwrap();
|
||||
let items = ops::lsp::lsp_completion(host, "main", RequestId(91), doc.id, Pos::new(0, 5), None).await.unwrap();
|
||||
let item = items.items.into_iter().find(|i| i.label == "embedded-server").unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_resolve_completion(host, "main", doc.id, item).await.unwrap().detail.as_deref(),
|
||||
Some("resolved")
|
||||
);
|
||||
let opened = fake.docs.lock().clone();
|
||||
assert_eq!(opened.len(), 1);
|
||||
assert_eq!(opened.values().next().unwrap(), " sql ");
|
||||
let virtual_uri = opened.keys().next().unwrap().clone();
|
||||
assert!(!ide_lsp::uri_to_path(&virtual_uri).unwrap().exists());
|
||||
let before = fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count();
|
||||
ops::lsp::lsp_completion(host, "main", RequestId(92), doc.id, Pos::new(0, 2), None).await.unwrap();
|
||||
assert_eq!(fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count(), before);
|
||||
let defs = ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Declaration).await;
|
||||
// Declaration isn't advertised; definition exercises mapping without a plugin definition provider.
|
||||
assert!(defs.is_err());
|
||||
let defs =
|
||||
ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Definition).await.unwrap();
|
||||
assert_eq!(defs[0].path.0, path);
|
||||
assert_eq!(defs[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
|
||||
let hover: Value = remote
|
||||
.request(
|
||||
"invoke",
|
||||
json!({"window":"main","cmd":"lsp_hover","args":{"request":93,"doc":doc.id,"pos":{"line":0,"col":5}}}),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(hover.to_string().contains("embedded hover"), "{hover}");
|
||||
ops::doc::edit(
|
||||
host,
|
||||
"main",
|
||||
doc.id,
|
||||
doc.version,
|
||||
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 7, insert: "SQL".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
ops::lsp::lsp_completion(host, "main", RequestId(94), doc.id, Pos::new(0, 5), None).await.unwrap();
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
|
||||
while fake.docs.lock().get(&virtual_uri).is_none_or(|s| s != " SQL ") {
|
||||
assert!(tokio::time::Instant::now() < deadline);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
plugins.set_enabled(ID, false).unwrap();
|
||||
while !fake.docs.lock().is_empty() {
|
||||
assert!(tokio::time::Instant::now() < deadline);
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(project.lsp.injected_document(doc.id, Pos::new(0, 5)), doc.id);
|
||||
ops::doc::close(host, "main", doc.id).unwrap();
|
||||
project.lsp.shutdown_all().await;
|
||||
project.lsp.set_servers_hook(Box::new(|_, _| Vec::new()));
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
for peer in peers.lock().drain(..) {
|
||||
peer.kill();
|
||||
}
|
||||
}
|
||||
@@ -6058,7 +6058,8 @@ fn api14_test_selection_traps_and_timeouts_are_unsupported() {
|
||||
f.host.refresh_run_configurations(&f.project);
|
||||
let started = Instant::now();
|
||||
assert_eq!(f.host.select_tests("main", "plugin:execution:dynamic", vec![vec![failure.into()]]).unwrap(), None);
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
// Selection is a user action: bounded by the command budget, not the edit path.
|
||||
assert!(started.elapsed() < Limits::default().timeout + Duration::from_secs(2));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Target debugging uses the same trusted plugin wrapper on desktop and the SSH server.
|
||||
#![cfg(unix)]
|
||||
use ide_core::IdeEvent;
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
|
||||
};
|
||||
use ide_language::LanguageRegistry;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc};
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registered_target_debugs_over_stdio_and_rechecks_program_grant() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let adapter = base.join("adapter.py");
|
||||
std::fs::write(&adapter, include_str!("../../ide-dap/tests/fixtures/target_adapter.py")).unwrap();
|
||||
let wrapper = base.join("wrapper");
|
||||
std::fs::write(
|
||||
&wrapper,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
|
||||
shift 2
|
||||
if [ "$1" = cat ]; then exec cat "$2" "$3"; fi
|
||||
export FAKE_ROOT=/container
|
||||
exec /usr/bin/python3 -u '{}'
|
||||
"#,
|
||||
adapter.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let e = sink_events.clone();
|
||||
Arc::new(move |event: IdeEvent| e.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
host.set_plugin_host(&plugins);
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
|
||||
let invocation =
|
||||
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
let id = plugins.run_targets(&root)[0].id.clone();
|
||||
let launch: ide_dap::LaunchConfig=serde_json::from_value(json!({"name":"Fake target","adapter":"debugpy","request":"launch","program":root.join("main.py"),"args":[],"cwd":root,"env":{},"extraJson":json!({"__runTarget":id}).to_string()})).unwrap();
|
||||
host.debugger
|
||||
.set_breakpoints(
|
||||
root.join("main.py"),
|
||||
vec![ide_dap::Breakpoint {
|
||||
line: 2,
|
||||
enabled: true,
|
||||
condition: None,
|
||||
hit_condition: None,
|
||||
log_message: None,
|
||||
}],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let session = ide_host::ops::debug::debug_start(&host, "main", launch.clone()).await.unwrap();
|
||||
let frames = host.debugger.stack_trace(session, 1).await.unwrap();
|
||||
assert_eq!(frames[0].path.as_ref().unwrap().to_path_buf(), root.join("main.py"));
|
||||
host.debugger.stop(session).await.unwrap();
|
||||
// External debug sources use the container reader even with no LSP running.
|
||||
let library = base.join("library.py");
|
||||
std::fs::write(&library, "container library").unwrap();
|
||||
let uri = format!(
|
||||
"openidea-target://{}/0/{}",
|
||||
percent_encoding::utf8_percent_encode(&id, percent_encoding::NON_ALPHANUMERIC),
|
||||
library.to_string_lossy().trim_start_matches('/')
|
||||
);
|
||||
let doc = ide_host::ops::doc::doc_open_virtual(&host, "main", uri).await.unwrap();
|
||||
assert!(doc.read_only);
|
||||
assert_eq!(doc.text, "container library");
|
||||
|
||||
std::fs::write(&wrapper, "#!/bin/sh\nexit 99\n").unwrap();
|
||||
let error = ide_host::ops::debug::debug_start(&host, "main", launch).await.unwrap_err();
|
||||
assert!(error.message.contains("changed") || error.message.contains("approved"), "{error:?}");
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! Real stdio LSP through a registered plugin wrapper, without a container daemon.
|
||||
#![cfg(unix)]
|
||||
use ide_core::{DocId, IdeEvent, LanguageId, Pos};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
plugins::{Consent, HostWorkspace, PluginEvent, PluginHost},
|
||||
};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry};
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::json;
|
||||
use std::{os::unix::fs::PermissionsExt, path::PathBuf, sync::Arc, time::Duration};
|
||||
|
||||
const SERVER: &str = r#"import sys,json,os
|
||||
root = 'file:///container' if os.environ.get('FAKE_IN_TARGET') else sys.argv[3]
|
||||
def send(v):
|
||||
body=json.dumps(v).encode(); sys.stdout.buffer.write(('Content-Length: %d\r\n\r\n'%len(body)).encode()+body); sys.stdout.buffer.flush()
|
||||
while True:
|
||||
headers={}
|
||||
while True:
|
||||
line=sys.stdin.buffer.readline()
|
||||
if not line: sys.exit(0)
|
||||
if line in (b'\r\n',b'\n'): break
|
||||
k,v=line.decode().split(':',1); headers[k.lower()]=v.strip()
|
||||
msg=json.loads(sys.stdin.buffer.read(int(headers['content-length'])))
|
||||
method=msg.get('method'); params=msg.get('params',{})
|
||||
if method=='initialize':
|
||||
assert params['rootUri']==root,params
|
||||
assert params['rootPath']==root.removeprefix('file://'),params
|
||||
assert params['workspaceFolders'][0]['uri']==root,params
|
||||
open(sys.argv[1],'a').write(json.dumps(params)+'\n')
|
||||
result={'capabilities':{'textDocumentSync':1,'definitionProvider':True,'documentLinkProvider':True}}
|
||||
elif method=='textDocument/didOpen':
|
||||
if params['textDocument']['uri'] != root+'/main.mock': continue
|
||||
send({'jsonrpc':'2.0','method':'textDocument/publishDiagnostics','params':{'uri':root+'/main.mock','diagnostics':[{'range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}},'message':'mapped diagnostic','severity':2}]}})
|
||||
continue
|
||||
elif method=='textDocument/documentLink':
|
||||
result=[{'target':sys.argv[2]+'#L2','range':{'start':{'line':0,'character':0},'end':{'line':0,'character':1}}}]
|
||||
elif method=='textDocument/definition':
|
||||
assert params['textDocument']['uri']==root+'/main.mock',params
|
||||
r={'start':{'line':0,'character':0},'end':{'line':0,'character':1}}
|
||||
result=[{'uri':root+'/definition.mock','range':r},{'uri':sys.argv[2],'range':r}]
|
||||
elif method=='shutdown': result=None
|
||||
elif method=='exit': sys.exit(0)
|
||||
else:
|
||||
if 'id' not in msg: continue
|
||||
result=None
|
||||
send({'jsonrpc':'2.0','id':msg['id'],'result':result})
|
||||
"#;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn initialize_definition_diagnostics_external_read_only_and_withdrawal() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let source = root.join("main.mock");
|
||||
std::fs::write(&source, "x").unwrap();
|
||||
std::fs::write(root.join("definition.mock"), "x").unwrap();
|
||||
let library = base.join("library with space.mock");
|
||||
std::fs::write(&library, "read only library").unwrap();
|
||||
let log = base.join("initialize.json");
|
||||
let reads = base.join("reads");
|
||||
let server = base.join("server.py");
|
||||
std::fs::write(&server, SERVER).unwrap();
|
||||
let wrapper = base.join("wrapper");
|
||||
std::fs::write(
|
||||
&wrapper,
|
||||
format!(
|
||||
r#"#!/bin/sh
|
||||
[ "$1" = wrapped ] && [ "$2" = --cwd=/container ] || exit 91
|
||||
shift 2
|
||||
if [ "$1" = cat ]; then printf '%s' "$3" > '{}'; fi
|
||||
export FAKE_IN_TARGET=1
|
||||
exec "$@"
|
||||
"#,
|
||||
reads.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut language: LanguageConfig = toml::from_str(
|
||||
"id='mock'\nname='Mock'\nextensions=['mock']\n[[language-servers]]\nname='mock-ls'\ncommand='python3'",
|
||||
)
|
||||
.unwrap();
|
||||
language.language_servers[0].args = vec![
|
||||
server.to_string_lossy().into(),
|
||||
log.to_string_lossy().into(),
|
||||
ide_lsp::path_to_uri_string(&library),
|
||||
ide_lsp::path_to_uri_string(&root),
|
||||
];
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("local"),
|
||||
languages: Arc::new(LanguageRegistry::from_configs(vec![language])),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let e = sink_events.clone();
|
||||
Arc::new(move |event: IdeEvent| e.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.open_project("main", &root).unwrap();
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
let plugins = Arc::new(
|
||||
PluginHost::new(base.join("plugins"), Arc::new(|_: Option<&str>, _: PluginEvent| {}), Limits::default())
|
||||
.unwrap(),
|
||||
);
|
||||
plugins.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
|
||||
host.set_plugin_host(&plugins);
|
||||
let package = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/execution");
|
||||
plugins.install_from_dir(&package, &Consent::all()).unwrap();
|
||||
plugins.choose_program("execution", "process:execution-tool", Some(&root), Some(&wrapper)).unwrap();
|
||||
let invocation =
|
||||
|| Invocation { client: Some("main".into()), workspace_root: Some(root.clone()), ..Default::default() };
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
let id = plugins.run_targets(&root)[0].id.clone();
|
||||
let project = host.project("main").unwrap();
|
||||
project
|
||||
.settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Project,
|
||||
"languageServerTargets",
|
||||
json!({"mock":{"target":id,"server":"mock-ls"}}),
|
||||
)
|
||||
.unwrap();
|
||||
let doc = DocId::next();
|
||||
project.lsp.did_open(doc, &source, &LanguageId::new("mock"), 1, "x");
|
||||
// Links have no previously recorded location origin. Their URI must identify
|
||||
// the right server by itself, including when its fragment is stripped.
|
||||
let links = project.lsp.document_links(doc).await.unwrap();
|
||||
let link = &links[0];
|
||||
assert_eq!(link.fragment.as_deref(), Some("L2"));
|
||||
assert!(link.url.is_none());
|
||||
let linked =
|
||||
ide_host::ops::doc::doc_open_virtual(&host, "main", link.path.as_ref().unwrap().to_string_lossy().into_owned())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(linked.read_only);
|
||||
let locations = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
project.lsp.goto(doc, Pos::new(0, 0), ide_lsp::types::GotoKind::Definition),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(locations[0].path.to_path_buf(), root.join("definition.mock"));
|
||||
assert!(log.exists(), "initialize reached the fake server with the mapped root");
|
||||
for _ in 0..200 {
|
||||
if events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))) { break; }
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(events.lock().iter().any(|e| matches!(e,IdeEvent::Diagnostics{path,items,..} if path.to_path_buf()==source && items.iter().any(|d| d.message=="mapped diagnostic"))));
|
||||
let uri = locations[1].uri.clone().expect("external paths are virtual documents");
|
||||
assert!(uri.starts_with("openidea-target:"));
|
||||
let opened = ide_host::ops::doc::doc_open_virtual(&host, "main", uri.clone()).await.unwrap();
|
||||
assert!(opened.read_only);
|
||||
assert_eq!(project.documents.text_of(std::path::Path::new(&uri)).unwrap(), "read only library");
|
||||
assert_eq!(std::fs::read_to_string(&reads).unwrap(), library.to_string_lossy());
|
||||
assert!(
|
||||
project
|
||||
.documents
|
||||
.apply_edits(opened.id, &[ide_core::TextEdit { range: Default::default(), new_text: "bad".into() }])
|
||||
.is_err()
|
||||
);
|
||||
for operation in [
|
||||
ide_core::FileEdit::Create { path: uri.clone().into(), overwrite: true },
|
||||
ide_core::FileEdit::Delete { path: uri.clone().into(), recursive: false },
|
||||
ide_core::FileEdit::Rename { from: uri.clone().into(), to: root.join("copy.mock").into() },
|
||||
ide_core::FileEdit::Edit { path: uri.clone().into(), version: None, edits: Vec::new() },
|
||||
] {
|
||||
let mut edit = ide_core::WorkspaceEdit { files: vec![operation], ..Default::default() };
|
||||
assert!(project.documents.prepare_workspace_edit(&mut edit).is_err());
|
||||
}
|
||||
// A library tab must not prevent ordinary project refactorings.
|
||||
project
|
||||
.documents
|
||||
.apply_workspace_edit(&ide_core::WorkspaceEdit {
|
||||
files: vec![ide_core::FileEdit::Create { path: root.join("created.mock").into(), overwrite: false }],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert!(root.join("created.mock").exists());
|
||||
// Changing the project setting restarts on the host, then back inside the target.
|
||||
let selection = json!({"mock":{"target":id,"server":"mock-ls"}});
|
||||
for value in [json!({}), selection.clone()] {
|
||||
project.settings.set(ide_workspace::SettingsScope::Project, "languageServerTargets", value.clone()).unwrap();
|
||||
project.lsp.reconfigure().await;
|
||||
let expected = if value == json!({}) { ide_lsp::path_to_uri_string(&root) } else { "file:///container".into() };
|
||||
wait_root(&log, &expected).await;
|
||||
}
|
||||
// Withdrawal invalidates reads, even when contents were cached earlier.
|
||||
plugins.run_command("execution", "untarget", None, invocation()).unwrap();
|
||||
assert!(project.lsp.virtual_document(&uri).await.is_err());
|
||||
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
|
||||
plugins.run_command("execution", "target", None, invocation()).unwrap();
|
||||
wait_root(&log, "file:///container").await;
|
||||
plugins.set_enabled("execution", false).unwrap();
|
||||
assert!(project.lsp.virtual_document(&uri).await.is_err());
|
||||
wait_root(&log, &ide_lsp::path_to_uri_string(&root)).await;
|
||||
host.shutdown().await;
|
||||
plugins.shutdown();
|
||||
}
|
||||
|
||||
async fn wait_root(log: &std::path::Path, expected: &str) {
|
||||
for _ in 0..500 {
|
||||
let text = std::fs::read_to_string(log).unwrap_or_default();
|
||||
if text
|
||||
.lines()
|
||||
.last()
|
||||
.and_then(|l| serde_json::from_str::<serde_json::Value>(l).ok())
|
||||
.is_some_and(|v| v["rootUri"] == expected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("server did not initialize with {expected}: {}", std::fs::read_to_string(log).unwrap_or_default());
|
||||
}
|
||||
@@ -248,6 +248,7 @@ struct Inner {
|
||||
/// Fired by [`Peer::kill`]; stops tasks and kills the child.
|
||||
kill: CancellationToken,
|
||||
pid: Option<u32>,
|
||||
transform: Option<Arc<dyn MessageTransform>>,
|
||||
/// Fired when the reader task has stopped (EOF, error or kill).
|
||||
reader_done: CancellationToken,
|
||||
/// Fired once the child process has exited and been reaped (after [`Peer::kill`],
|
||||
@@ -255,11 +256,38 @@ struct Inner {
|
||||
exited: CancellationToken,
|
||||
}
|
||||
|
||||
/// Translates protocol values before serialization and after parsing, including
|
||||
/// responses to requests initiated by the server.
|
||||
pub trait MessageTransform: Send + Sync + 'static {
|
||||
fn outgoing(&self, value: &mut Value);
|
||||
fn incoming(&self, value: &mut Value);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct TransportOptions {
|
||||
pub framing: Framing,
|
||||
pub transform: Option<Arc<dyn MessageTransform>>,
|
||||
}
|
||||
|
||||
impl From<Framing> for TransportOptions {
|
||||
fn from(framing: Framing) -> Self {
|
||||
Self { framing, transform: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn send_value(&self, value: &Value) -> Result<(), RpcError> {
|
||||
if self.transport_closed.load(Ordering::Acquire) {
|
||||
return Err(RpcError::Closed);
|
||||
}
|
||||
let mut translated;
|
||||
let value = if let Some(transform) = &self.transform {
|
||||
translated = value.clone();
|
||||
transform.outgoing(&mut translated);
|
||||
&translated
|
||||
} else {
|
||||
value
|
||||
};
|
||||
let body = serde_json::to_vec(value).map_err(|e| RpcError::Protocol(e.to_string()))?;
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "--> {}", String::from_utf8_lossy(&body));
|
||||
self.out.send(body).map_err(|_| RpcError::Closed)
|
||||
@@ -387,6 +415,19 @@ impl Peer {
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
framing: Framing,
|
||||
) -> std::io::Result<Self> {
|
||||
Self::spawn_transport(command, args, cwd, env, handler, on_stderr, framing.into())
|
||||
}
|
||||
|
||||
/// Starts a stdio peer with message translation installed before any IO.
|
||||
pub fn spawn_transport(
|
||||
command: &str,
|
||||
args: &[String],
|
||||
cwd: &std::path::Path,
|
||||
env: &[(String, String)],
|
||||
handler: Arc<dyn Handler>,
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
options: TransportOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
use std::process::Stdio;
|
||||
// Windows resolves a bare name only as `.exe`; find the file a child
|
||||
@@ -412,7 +453,7 @@ impl Peer {
|
||||
let pid = child.id();
|
||||
|
||||
let on_output: Arc<dyn Fn(String) + Send + Sync> = Arc::from(on_stderr);
|
||||
let peer = Self::start(stdout, stdin, handler, false, pid, framing, Some(on_output.clone()));
|
||||
let peer = Self::start(stdout, stdin, handler, false, pid, options, Some(on_output.clone()));
|
||||
|
||||
let stderr_task = stderr.map(|stderr| {
|
||||
let name = command.to_owned();
|
||||
@@ -466,7 +507,7 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None, Framing::ContentLength, None)
|
||||
Self::start(reader, writer, handler, true, None, Framing::ContentLength.into(), None)
|
||||
}
|
||||
|
||||
/// [`connect`](Self::connect) with the given wire framing.
|
||||
@@ -475,7 +516,7 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
Self::start(reader, writer, handler, true, None, framing, None)
|
||||
Self::start(reader, writer, handler, true, None, framing.into(), None)
|
||||
}
|
||||
|
||||
/// [`Peer::connect`] over streams served by `child` (a server reached over a socket
|
||||
@@ -492,7 +533,7 @@ impl Peer {
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength, None);
|
||||
let peer = Self::start(reader, writer, handler, true, child.id(), Framing::ContentLength.into(), None);
|
||||
peer.supervise(child, None);
|
||||
peer
|
||||
}
|
||||
@@ -503,13 +544,14 @@ impl Peer {
|
||||
handler: Arc<dyn Handler>,
|
||||
close_on_eof: bool,
|
||||
pid: Option<u32>,
|
||||
framing: Framing,
|
||||
options: TransportOptions,
|
||||
on_noise: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
||||
) -> Self
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let TransportOptions { framing, transform } = options;
|
||||
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (closed, _) = watch::channel(None);
|
||||
let inner = Arc::new(Inner {
|
||||
@@ -520,6 +562,7 @@ impl Peer {
|
||||
closed,
|
||||
kill: CancellationToken::new(),
|
||||
pid,
|
||||
transform,
|
||||
reader_done: CancellationToken::new(),
|
||||
exited: CancellationToken::new(),
|
||||
});
|
||||
@@ -785,13 +828,16 @@ async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value,
|
||||
|
||||
fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, body: &[u8]) {
|
||||
tracing::trace!(target: "ide_jsonrpc::wire", "<-- {}", String::from_utf8_lossy(body));
|
||||
let msg: Value = match serde_json::from_slice(body) {
|
||||
let mut msg: Value = match serde_json::from_slice(body) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!("jsonrpc: unparsable message: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Some(transform) = &inner.transform {
|
||||
transform.incoming(&mut msg);
|
||||
}
|
||||
let Value::Object(mut obj) = msg else {
|
||||
tracing::warn!("jsonrpc: ignoring non-object message");
|
||||
return;
|
||||
|
||||
@@ -176,11 +176,30 @@ pub struct LanguageServerConfig {
|
||||
/// Set on plugin languages: start exactly this approved program.
|
||||
#[serde(skip)]
|
||||
pub pinned: Option<ProgramPin>,
|
||||
/// Host-only transport configuration; never accepted from language definitions.
|
||||
#[serde(skip)]
|
||||
pub target: Option<ServerTarget>,
|
||||
/// Separates instances when languages sharing a server select different targets.
|
||||
#[serde(skip)]
|
||||
pub target_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A prepared plugin wrapper. The LSP client never resolves container binaries on the host.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ServerTarget {
|
||||
pub id: String,
|
||||
pub origin: Option<ide_core::ServerId>,
|
||||
pub project_root: PathBuf,
|
||||
pub container_root: String,
|
||||
pub mapping: ide_core::path_mapping::PathMapping,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub struct DebuggerConfig {
|
||||
/// Placement in plugin run targets; absent means unsupported.
|
||||
#[serde(default)]
|
||||
pub target_placement: Option<String>,
|
||||
/// DAP adapter name, e.g. `codelldb`, `debugpy`, `js-debug`, `delve`.
|
||||
pub adapter: String,
|
||||
pub command: String,
|
||||
|
||||
@@ -468,6 +468,25 @@ impl ServerCaps {
|
||||
self.restriction = restriction;
|
||||
return;
|
||||
}
|
||||
// JDT LS registers the commands of bundles that were not loaded when it initialized.
|
||||
if method == "workspace/executeCommand"
|
||||
&& let Some(added) = options.and_then(|o| o.get("commands")).and_then(Value::as_array)
|
||||
{
|
||||
if !self.raw.get("executeCommandProvider").is_some_and(Value::is_object) {
|
||||
self.raw["executeCommandProvider"] = json!({ "commands": [] });
|
||||
}
|
||||
let provider = &mut self.raw["executeCommandProvider"];
|
||||
if !provider.get("commands").is_some_and(Value::is_array) {
|
||||
provider["commands"] = json!([]);
|
||||
}
|
||||
let commands = provider["commands"].as_array_mut().expect("commands");
|
||||
for command in added {
|
||||
if !commands.contains(command) {
|
||||
commands.push(command.clone());
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if method == "textDocument/didSave" {
|
||||
let include = options.and_then(|o| o.get("includeText")).and_then(Value::as_bool).unwrap_or(false);
|
||||
self.save = Some(include);
|
||||
@@ -566,6 +585,13 @@ mod tests {
|
||||
assert!(c.file_operation("willRename").is_some());
|
||||
c.unregister("workspace/willRenameFiles");
|
||||
assert!(c.file_operation("willRename").is_none());
|
||||
// Commands registered later join those announced at initialization.
|
||||
let mut c = ServerCaps::parse(json!({ "executeCommandProvider": { "commands": ["a"] } }));
|
||||
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b", "a"] })));
|
||||
assert_eq!(c.commands(), vec!["a", "b"]);
|
||||
let mut c = ServerCaps::parse(json!({}));
|
||||
c.register("workspace/executeCommand", Some(&json!({ "commands": ["b"] })));
|
||||
assert_eq!(c.commands(), vec!["b"]);
|
||||
}
|
||||
|
||||
fn restriction(features: &[&str], kinds: &[&str]) -> Restriction {
|
||||
|
||||
@@ -17,15 +17,30 @@ pub type Lookup<'a> = Box<dyn Fn(&Path) -> Option<Arc<DocText>> + Send + Sync +
|
||||
|
||||
/// Converts server positions to IDE coordinates. For UTF-16 servers this is
|
||||
/// the identity; otherwise line text comes from open documents or disk.
|
||||
type MapLocation<'a> = Box<dyn Fn(Location) -> Option<Location> + Send + Sync + 'a>;
|
||||
type MapEdit<'a> = Box<dyn Fn(&mut WorkspaceEdit) + Send + Sync + 'a>;
|
||||
|
||||
pub struct Conv<'a> {
|
||||
pub enc: Encoding,
|
||||
map_location: Option<MapLocation<'a>>,
|
||||
map_edit: Option<MapEdit<'a>>,
|
||||
lookup: Lookup<'a>,
|
||||
cache: Mutex<HashMap<PathBuf, Option<Arc<DocText>>>>,
|
||||
}
|
||||
|
||||
impl<'a> Conv<'a> {
|
||||
pub fn new(enc: Encoding, lookup: impl Fn(&Path) -> Option<Arc<DocText>> + Send + Sync + 'a) -> Self {
|
||||
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()) }
|
||||
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()), map_location: None, map_edit: None }
|
||||
}
|
||||
|
||||
pub fn with_injections(
|
||||
mut self,
|
||||
location: impl Fn(Location) -> Option<Location> + Send + Sync + 'a,
|
||||
edit: impl Fn(&mut WorkspaceEdit) + Send + Sync + 'a,
|
||||
) -> Self {
|
||||
self.map_location = Some(Box::new(location));
|
||||
self.map_edit = Some(Box::new(edit));
|
||||
self
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -62,7 +77,11 @@ impl<'a> Conv<'a> {
|
||||
pub fn location(&self, loc: &lsp::Location) -> Option<Location> {
|
||||
let (path, uri) = uri_target(loc.uri.as_str());
|
||||
let range = self.range(&path, loc.range);
|
||||
Some(Location { path: path.into(), range, uri })
|
||||
let loc = Location { path: path.into(), range, uri };
|
||||
match &self.map_location {
|
||||
Some(map) => map(loc),
|
||||
None => Some(loc),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn text_edit(&self, path: &Path, e: lsp::TextEdit) -> TextEdit {
|
||||
@@ -662,6 +681,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
}
|
||||
}
|
||||
let mut edit = WorkspaceEdit { files, ..Default::default() };
|
||||
if let Some(map) = &conv.map_edit {
|
||||
map(&mut edit);
|
||||
}
|
||||
edit.deduplicate();
|
||||
edit
|
||||
}
|
||||
@@ -829,7 +851,13 @@ pub fn workspace_symbols(conv: &Conv, r: lsp::WorkspaceSymbolResponse) -> Vec<Wo
|
||||
lsp::OneOf::Left(l) => conv.location(&l)?,
|
||||
lsp::OneOf::Right(w) => {
|
||||
let (path, uri) = uri_target(w.uri.as_str());
|
||||
Location { path: path.into(), range: Range::default(), uri }
|
||||
{
|
||||
let loc = Location { path: path.into(), range: Range::default(), uri };
|
||||
match &conv.map_location {
|
||||
Some(map) => map(loc)?,
|
||||
None => loc,
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Some(WorkspaceSymbol { name: s.name, kind: symbol_kind(s.kind), container: s.container_name, location })
|
||||
@@ -949,6 +977,7 @@ pub fn document_link(conv: &Conv, path: &Path, l: lsp::DocumentLink) -> Option<D
|
||||
let target = target.as_str();
|
||||
let (file, url) = match uri_to_path(target) {
|
||||
Some(p) => (Some(p), None),
|
||||
None if target.starts_with("openidea-target:") => (Some(PathBuf::from(target.split('#').next()?)), None),
|
||||
None => (None, Some(target.to_owned())),
|
||||
};
|
||||
let fragment = file
|
||||
@@ -1442,6 +1471,11 @@ mod tests {
|
||||
assert_eq!(file.path.as_deref(), Some(Path::new("/p/a b.md")));
|
||||
assert_eq!(file.fragment.as_deref(), Some("L12"));
|
||||
assert_eq!(file.range, Range::new(p(0, 6), p(0, 15)));
|
||||
let virtual_link =
|
||||
document_link(&conv, here, link(Some("openidea-target://target/7/usr/lib/a.php#L2"))).unwrap();
|
||||
assert_eq!(virtual_link.path.as_deref(), Some(Path::new("openidea-target://target/7/usr/lib/a.php")));
|
||||
assert_eq!(virtual_link.fragment.as_deref(), Some("L2"));
|
||||
assert!(virtual_link.url.is_none());
|
||||
let web = document_link(&conv, here, link(Some("https://example.com/#top"))).unwrap();
|
||||
assert_eq!((web.path, web.url.as_deref(), web.fragment), (None, Some("https://example.com/#top"), None));
|
||||
assert!(document_link(&conv, here, link(None)).is_none());
|
||||
|
||||
@@ -139,6 +139,9 @@ impl ClientHandler {
|
||||
}
|
||||
};
|
||||
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
|
||||
if !mgr.accepts_injected_diagnostics(&path, params.version) {
|
||||
return;
|
||||
}
|
||||
// Files opened only for a request (references search) are not the user's problems.
|
||||
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !mgr.docs.lock().values().any(|d| d.path == path))
|
||||
|
||||
@@ -0,0 +1,769 @@
|
||||
//! Masked file documents for plugin-provided embedded languages.
|
||||
use crate::{
|
||||
LspManager, convert,
|
||||
server::Server,
|
||||
text::Encoding,
|
||||
types::{Command, CompletionItem},
|
||||
};
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeResult, LanguageId, Location, Pos, Range, WorkspaceEdit};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
pub const MAX_INJECTED_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
const MAX_DOCUMENTS: usize = 32;
|
||||
const MARKER: &str = ".openidea-injected-";
|
||||
const ORIGIN: &str = "$openideaInjection";
|
||||
pub(super) const COMMAND: &str = "$openideaInjectedCommand";
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct InjectedRange {
|
||||
pub range: Range,
|
||||
pub language: LanguageId,
|
||||
}
|
||||
|
||||
/// Runs on a blocking worker, with the host's unsaved snapshot.
|
||||
pub type InjectionProvider = dyn Fn(&Path, &LanguageId, u32, &str) -> Vec<InjectedRange> + Send + Sync;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Document {
|
||||
host: DocId,
|
||||
host_path: PathBuf,
|
||||
version: u32,
|
||||
path: PathBuf,
|
||||
injection: InjectedRange,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct State {
|
||||
pub gate: Mutex<()>,
|
||||
provider: RwLock<Option<Arc<InjectionProvider>>>,
|
||||
documents: RwLock<HashMap<DocId, Document>>,
|
||||
refreshed: Mutex<HashMap<DocId, u32>>,
|
||||
refresh: tokio::sync::Mutex<()>,
|
||||
generation: AtomicU64,
|
||||
}
|
||||
|
||||
fn reserved(path: &Path) -> bool {
|
||||
path.file_name().is_some_and(|s| {
|
||||
let name = s.to_string_lossy();
|
||||
name.rsplit_once(MARKER)
|
||||
.and_then(|(_, suffix)| suffix.split_once('.'))
|
||||
.is_some_and(|(id, _)| !id.is_empty() && id.bytes().all(|c| c.is_ascii_digit()))
|
||||
}) && !path.exists()
|
||||
}
|
||||
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
range.start <= pos && pos < range.end
|
||||
}
|
||||
|
||||
fn clip(bounds: Range, range: Range) -> Option<Range> {
|
||||
if range.start > range.end {
|
||||
return None;
|
||||
}
|
||||
if range.is_empty() {
|
||||
return contains(bounds, range.start).then_some(range);
|
||||
}
|
||||
let start = bounds.start.max(range.start);
|
||||
let end = bounds.end.min(range.end);
|
||||
(start < end).then_some(Range::new(start, end))
|
||||
}
|
||||
|
||||
fn confined(bounds: Range, range: Range) -> bool {
|
||||
range.start <= range.end && bounds.start <= range.start && range.end <= bounds.end
|
||||
}
|
||||
|
||||
fn mask(text: &str, range: Range) -> String {
|
||||
let mut at = Pos::default();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for c in text.chars() {
|
||||
if contains(range, at) || matches!(c, '\n' | '\r') {
|
||||
out.push(c);
|
||||
} else {
|
||||
out.extend(std::iter::repeat_n(' ', c.len_utf16()));
|
||||
}
|
||||
if c == '\n' {
|
||||
at.line += 1;
|
||||
at.col = 0;
|
||||
} else if c != '\r' {
|
||||
at.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn set_injection_provider(&self, provider: Arc<InjectionProvider>) {
|
||||
*self.injections.provider.write() = Some(provider);
|
||||
}
|
||||
|
||||
pub(super) fn schedule_injections(&self, doc: DocId) {
|
||||
if self.injections.provider.read().is_none() || self.is_injected_doc(doc) {
|
||||
return;
|
||||
}
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let Ok(snap) = self.snapshot(doc) else { return };
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
if me.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
|
||||
let _ = me.refresh_injections(doc).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Refresh on demand as well as after edits: point requests need not wait for the debounce.
|
||||
pub async fn refresh_injections(&self, doc: DocId) -> IdeResult<()> {
|
||||
let _refresh = self.injections.refresh.lock().await;
|
||||
let generation = self.injections.generation.load(Ordering::Acquire);
|
||||
let Ok(snap) = self.snapshot(doc) else { return Ok(()) };
|
||||
if self.is_injected_doc(doc) || self.injections.refreshed.lock().get(&doc) == Some(&(snap.version as u32)) {
|
||||
return Ok(());
|
||||
}
|
||||
let provider = self.injections.provider.read().clone();
|
||||
let ranges = if snap.text.as_str().len() <= MAX_INJECTED_DOCUMENT_BYTES && !uri_is_virtual(&snap.path) {
|
||||
if let Some(provider) = provider {
|
||||
let (path, language, text, version) = (snap.path, snap.language, snap.text, snap.version);
|
||||
tokio::task::spawn_blocking(move || provider(&path, &language, version as u32, text.as_str()))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))?
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
if let Some(me) = self.me.upgrade() {
|
||||
tokio::task::spawn_blocking(move || me.set_injections_at(doc, snap.version as u32, ranges, generation))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))?;
|
||||
}
|
||||
if self.injection_generation() != generation || !self.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Revision of plugin providers used to reject an obsolete refresh.
|
||||
pub fn injection_generation(&self) -> u64 {
|
||||
self.injections.generation.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn set_injections(&self, host: DocId, version: u32, ranges: Vec<InjectedRange>) {
|
||||
self.set_injections_at(host, version, ranges, self.injection_generation());
|
||||
}
|
||||
|
||||
pub fn set_injections_at(&self, host: DocId, version: u32, mut ranges: Vec<InjectedRange>, generation: u64) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if generation != self.injection_generation() {
|
||||
return;
|
||||
}
|
||||
let Ok(snap) = self.snapshot(host) else { return };
|
||||
if snap.version as u32 != version || self.is_injected_doc(host) {
|
||||
return;
|
||||
}
|
||||
if snap.text.as_str().len() > MAX_INJECTED_DOCUMENT_BYTES {
|
||||
ranges.clear();
|
||||
}
|
||||
ranges.sort_by_key(|r| r.range);
|
||||
let mut end = Pos::default();
|
||||
ranges.retain(|i| {
|
||||
let r = i.range;
|
||||
let valid_pos = |p: Pos| {
|
||||
(p.line as usize) < snap.text.line_count()
|
||||
&& snap.text.lsp_to_ide(Encoding::Utf8, snap.text.ide_to_lsp(Encoding::Utf8, p)) == p
|
||||
};
|
||||
if r.start >= r.end
|
||||
|| r.start < end
|
||||
|| !valid_pos(r.start)
|
||||
|| !valid_pos(r.end)
|
||||
|| i.language == snap.language
|
||||
|| self.language_servers(&i.language).is_empty()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
end = r.end;
|
||||
true
|
||||
});
|
||||
ranges.truncate(MAX_DOCUMENTS);
|
||||
let mut old: Vec<_> = self
|
||||
.injections
|
||||
.documents
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, d)| d.host == host)
|
||||
.map(|(id, d)| (*id, d.clone()))
|
||||
.collect();
|
||||
old.sort_by_key(|(_, d)| d.injection.range);
|
||||
let mut retained = Vec::new();
|
||||
for (slot, injection) in ranges.into_iter().enumerate() {
|
||||
let existing =
|
||||
old.iter().position(|(id, d)| !retained.contains(id) && d.injection == injection).or_else(|| {
|
||||
old.get(slot)
|
||||
.filter(|(id, d)| !retained.contains(id) && d.injection.language == injection.language)
|
||||
.map(|_| slot)
|
||||
});
|
||||
let text = mask(snap.text.as_str(), injection.range);
|
||||
if let Some(index) = existing {
|
||||
let (id, mut d) = old[index].clone();
|
||||
if d.injection != injection {
|
||||
self.clear_injected_diagnostics(&d);
|
||||
}
|
||||
d.version = version;
|
||||
d.injection = injection;
|
||||
d.active = true;
|
||||
self.injections.documents.write().insert(id, d);
|
||||
self.sync_injected(id, &text);
|
||||
retained.push(id);
|
||||
} else {
|
||||
let Some(cfg) = self.languages.get(&injection.language) else { continue };
|
||||
let ext = cfg
|
||||
.extensions
|
||||
.iter()
|
||||
.find(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
.map(String::as_str)
|
||||
.unwrap_or("txt");
|
||||
let mut id = DocId::next();
|
||||
let path = loop {
|
||||
let path = snap.path.with_file_name(format!(
|
||||
".{}{MARKER}{}.{}",
|
||||
snap.path.file_name().unwrap_or_default().to_string_lossy(),
|
||||
id.0,
|
||||
ext
|
||||
));
|
||||
if !path.exists() {
|
||||
break path;
|
||||
}
|
||||
id = DocId::next();
|
||||
};
|
||||
self.injections.documents.write().insert(
|
||||
id,
|
||||
Document {
|
||||
host,
|
||||
host_path: snap.path.clone(),
|
||||
version,
|
||||
path: path.clone(),
|
||||
injection: injection.clone(),
|
||||
active: true,
|
||||
},
|
||||
);
|
||||
let servers = self.servers_for(&injection.language, &path);
|
||||
self.attach(
|
||||
id,
|
||||
path.clone(),
|
||||
crate::uri::path_to_uri(&path),
|
||||
&injection.language,
|
||||
version,
|
||||
&text,
|
||||
servers,
|
||||
);
|
||||
retained.push(id);
|
||||
}
|
||||
}
|
||||
for (id, d) in old {
|
||||
if !retained.contains(&id) {
|
||||
self.injections.documents.write().remove(&id);
|
||||
self.close_document(id);
|
||||
self.clear_injected_diagnostics(&d);
|
||||
}
|
||||
}
|
||||
self.injections.refreshed.lock().insert(host, version);
|
||||
}
|
||||
|
||||
/// Plugin enable/disable/reload changes the ranges even without a buffer edit.
|
||||
pub fn injection_providers_changed(&self) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
self.injections.generation.fetch_add(1, Ordering::AcqRel);
|
||||
self.injections.refreshed.lock().clear();
|
||||
let hosts: Vec<_> = self.docs.lock().keys().copied().collect();
|
||||
for doc in hosts {
|
||||
if !self.is_injected_doc(doc) {
|
||||
self.close_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_injected_event(&self, mut event: ide_core::IdeEvent) -> Option<ide_core::IdeEvent> {
|
||||
if let ide_core::IdeEvent::Diagnostics { path, .. } = &mut event
|
||||
&& reserved(path)
|
||||
{
|
||||
let docs = self.injections.documents.read();
|
||||
let d = docs.values().find(|d| d.path == path.as_ref())?;
|
||||
*path = d.host_path.clone().into();
|
||||
}
|
||||
Some(event)
|
||||
}
|
||||
|
||||
pub(super) fn injected_request_stamp(&self, params: &Value) -> Option<(DocId, i32)> {
|
||||
let path = crate::uri::uri_to_path(params.pointer("/textDocument/uri")?.as_str()?)?;
|
||||
if !reserved(&path) {
|
||||
return None;
|
||||
}
|
||||
self.docs.lock().iter().find(|(_, d)| d.path == path).map(|(id, d)| (*id, d.version))
|
||||
}
|
||||
|
||||
pub(super) fn check_injected_request(&self, doc: DocId, version: i32) -> IdeResult<()> {
|
||||
let active = self.injections.documents.read().get(&doc).is_some_and(|d| d.active);
|
||||
if !active || !self.snapshot(doc).is_ok_and(|s| s.version == version) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn accepts_injected_diagnostics(&self, path: &Path, version: Option<i32>) -> bool {
|
||||
if !reserved(path) {
|
||||
return true;
|
||||
}
|
||||
let docs = self.injections.documents.read();
|
||||
docs.iter().any(|(id, d)| {
|
||||
d.active && d.path == path && self.snapshot(*id).is_ok_and(|s| version.is_none_or(|v| s.version == v))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn injected_document(&self, host: DocId, pos: Pos) -> DocId {
|
||||
let version = self.snapshot(host).ok().map(|s| s.version as u32);
|
||||
self.injections
|
||||
.documents
|
||||
.read()
|
||||
.iter()
|
||||
.find(|(_, d)| d.host == host && d.active && Some(d.version) == version && contains(d.injection.range, pos))
|
||||
.map_or(host, |(id, _)| *id)
|
||||
}
|
||||
|
||||
pub fn injected_request_range(&self, doc: DocId, range: Range) -> Range {
|
||||
self.clip_injected_range(doc, range).unwrap_or(range)
|
||||
}
|
||||
|
||||
pub(super) fn is_injected_doc(&self, doc: DocId) -> bool {
|
||||
self.injections.documents.read().contains_key(&doc)
|
||||
}
|
||||
|
||||
pub(super) fn clip_injected_range(&self, doc: DocId, range: Range) -> Option<Range> {
|
||||
match self.injections.documents.read().get(&doc) {
|
||||
Some(d) if d.active => clip(d.injection.range, range),
|
||||
Some(_) => None,
|
||||
None => Some(range),
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_injected(&self, id: DocId, text: &str) {
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.get_mut(&id) else { return };
|
||||
if d.text.as_str() == text {
|
||||
return;
|
||||
}
|
||||
d.version += 1;
|
||||
Arc::make_mut(&mut d.text).set(text);
|
||||
for a in &d.servers {
|
||||
let Some(run) = a.server.running() else { continue };
|
||||
if a.opened_gen != Some(run.generation) || run.caps.read().sync == crate::SyncKind::None {
|
||||
continue;
|
||||
}
|
||||
let _ = run.peer.notify(
|
||||
"textDocument/didChange",
|
||||
json!({
|
||||
"textDocument": { "uri": d.uri, "version": d.version }, "contentChanges": [{ "text": text }]
|
||||
}),
|
||||
);
|
||||
}
|
||||
drop(docs);
|
||||
self.schedule_pull(id, None, Duration::ZERO);
|
||||
}
|
||||
|
||||
pub(super) fn invalidate_injections(&self, host: DocId) {
|
||||
self.injections.refreshed.lock().remove(&host);
|
||||
let old: Vec<_> = self
|
||||
.injections
|
||||
.documents
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, d)| d.host == host)
|
||||
.map(|(id, d)| (*id, d.clone()))
|
||||
.collect();
|
||||
if old.is_empty() {
|
||||
return;
|
||||
}
|
||||
for (id, d) in old {
|
||||
if let Some(d) = self.injections.documents.write().get_mut(&id) {
|
||||
d.active = false;
|
||||
}
|
||||
self.clear_injected_diagnostics(&d);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn close_injections(&self, host: DocId) {
|
||||
self.injections.refreshed.lock().remove(&host);
|
||||
let old: Vec<_> = self
|
||||
.injections
|
||||
.documents
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, d)| d.host == host)
|
||||
.map(|(id, d)| (*id, d.clone()))
|
||||
.collect();
|
||||
for (id, d) in old {
|
||||
self.injections.documents.write().remove(&id);
|
||||
self.close_document(id);
|
||||
self.clear_injected_diagnostics(&d);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_injected_location(&self, mut loc: Location) -> Option<Location> {
|
||||
let docs = self.injections.documents.read();
|
||||
if let Some(d) = docs.values().find(|d| d.path == loc.path.as_ref()) {
|
||||
if !d.active {
|
||||
return None;
|
||||
}
|
||||
loc.range = clip(d.injection.range, loc.range)?;
|
||||
loc.path = d.host_path.clone().into();
|
||||
loc.uri = None;
|
||||
Some(loc)
|
||||
} else {
|
||||
(!reserved(&loc.path)).then_some(loc)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn map_injected_edit(&self, edit: &mut WorkspaceEdit) {
|
||||
let docs = self.injections.documents.read();
|
||||
edit.files.retain_mut(|f| match f {
|
||||
FileEdit::Edit { path, edits, version } => {
|
||||
if let Some(d) = docs.values().find(|d| d.path == path.as_ref()) {
|
||||
if !d.active {
|
||||
return false;
|
||||
}
|
||||
// Clipping replacements changes their meaning; discard edits crossing a boundary.
|
||||
edits.retain(|e| confined(d.injection.range, e.range));
|
||||
*path = d.host_path.clone().into();
|
||||
*version = Some(d.version as i32);
|
||||
!edits.is_empty()
|
||||
} else {
|
||||
!reserved(path)
|
||||
}
|
||||
}
|
||||
FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => {
|
||||
!reserved(path) && !docs.values().any(|d| d.path == path.as_ref())
|
||||
}
|
||||
FileEdit::Rename { from, to } => {
|
||||
!reserved(from)
|
||||
&& !reserved(to)
|
||||
&& !docs.values().any(|d| d.path == from.as_ref() || d.path == to.as_ref())
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn provides_injected_command(&self, host: DocId, server: Option<ide_core::ServerId>) -> bool {
|
||||
let ids: Vec<_> = self
|
||||
.injections
|
||||
.documents
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(id, d)| d.host == host || **id == host)
|
||||
.map(|(id, _)| *id)
|
||||
.collect();
|
||||
let docs = self.docs.lock();
|
||||
ids.iter()
|
||||
.any(|id| docs.get(id).is_some_and(|d| d.servers.iter().any(|a| server.is_none_or(|s| a.server.id == s))))
|
||||
}
|
||||
|
||||
pub(super) fn tag_injected_command(&self, doc: DocId, server: ide_core::ServerId, command: &mut Option<Command>) {
|
||||
let Some(original) = command.take() else { return };
|
||||
if self.is_injected_doc(doc) {
|
||||
let mut data = Some(serde_json::to_value(&original).expect("command is JSON"));
|
||||
self.tag_injected_origin(doc, &mut data);
|
||||
*command = Some(Command {
|
||||
title: original.title,
|
||||
command: COMMAND.into(),
|
||||
arguments: vec![json!({ "origin": data, "server": server })],
|
||||
});
|
||||
} else {
|
||||
*command = Some(original);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn injected_command(
|
||||
&self,
|
||||
host: DocId,
|
||||
command: Command,
|
||||
) -> IdeResult<(DocId, Command, Option<ide_core::ServerId>)> {
|
||||
if command.command != COMMAND {
|
||||
return Ok((host, command, None));
|
||||
}
|
||||
let payload = command.arguments.first().ok_or_else(IdeError::cancelled)?;
|
||||
let origin = payload.pointer("/origin/$openideaInjection").ok_or_else(IdeError::cancelled)?;
|
||||
let doc =
|
||||
origin["doc"].as_u64().and_then(|id| u32::try_from(id).ok()).map(DocId).ok_or_else(IdeError::cancelled)?;
|
||||
// Acceptance may already have edited the host. Keep the server/document identity;
|
||||
// execute refreshes its mask first, and cannot revive a disappeared injection.
|
||||
if !self.injections.documents.read().get(&doc).is_some_and(|d| d.host == host || doc == host) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let command = serde_json::from_value(origin["data"].clone()).map_err(|_| IdeError::cancelled())?;
|
||||
let server =
|
||||
payload["server"].as_u64().map(|id| ide_core::ServerId(id as u32)).ok_or_else(IdeError::cancelled)?;
|
||||
Ok((doc, command, Some(server)))
|
||||
}
|
||||
|
||||
pub(super) fn tag_injected_origin(&self, doc: DocId, data: &mut Option<Value>) {
|
||||
if let Some(d) = self.injections.documents.read().get(&doc) {
|
||||
*data = Some(json!({ ORIGIN: { "doc": doc, "version": d.version, "data": data.take() } }));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn resolve_injected_origin(&self, host: DocId, data: &mut Option<Value>) -> IdeResult<DocId> {
|
||||
let Some(origin) = data.as_ref().and_then(|v| v.get(ORIGIN)) else { return Ok(host) };
|
||||
let doc = origin["doc"].as_u64().map(|id| DocId(id as u32)).ok_or_else(IdeError::cancelled)?;
|
||||
let docs = self.injections.documents.read();
|
||||
let d = docs
|
||||
.get(&doc)
|
||||
.filter(|d| {
|
||||
(d.host == host || doc == host) && d.active && origin["version"].as_u64() == Some(d.version.into())
|
||||
})
|
||||
.ok_or_else(IdeError::cancelled)?;
|
||||
if self.snapshot(d.host)?.version as u32 != d.version {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
*data = origin.get("data").filter(|v| !v.is_null()).cloned();
|
||||
Ok(doc)
|
||||
}
|
||||
|
||||
pub(super) fn bound_completion(&self, doc: DocId, item: &mut CompletionItem) -> bool {
|
||||
let docs = self.injections.documents.read();
|
||||
let Some(d) = docs.get(&doc) else { return !self.snapshot(doc).is_ok_and(|s| reserved(&s.path)) };
|
||||
if !d.active {
|
||||
return false;
|
||||
}
|
||||
for r in [&mut item.replace_range, &mut item.replace_range_tab].into_iter().flatten() {
|
||||
let Some(clipped) = clip(d.injection.range, *r) else { return false };
|
||||
*r = clipped;
|
||||
}
|
||||
item.additional_edits.retain(|e| confined(d.injection.range, e.range));
|
||||
drop(docs);
|
||||
self.tag_injected_command(doc, item.server, &mut item.command);
|
||||
self.tag_injected_origin(doc, &mut item.data);
|
||||
true
|
||||
}
|
||||
|
||||
fn clear_injected_diagnostics(&self, d: &Document) {
|
||||
let servers = self.servers.lock().clone();
|
||||
for server in servers {
|
||||
server.set_diagnostics(d.path.clone(), Vec::new());
|
||||
server.clear_pulled(&d.path);
|
||||
let items = server.diagnostics_for(&d.host_path);
|
||||
let enc = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
|
||||
self.emit_diagnostics(&server, d.host_path.clone(), items, enc);
|
||||
}
|
||||
}
|
||||
|
||||
/// One event per host/source includes every fragment and the server's native host diagnostics.
|
||||
pub(super) fn injected_diagnostics(
|
||||
&self,
|
||||
server: &Server,
|
||||
path: &Path,
|
||||
enc: Encoding,
|
||||
native: Vec<lsp_types::Diagnostic>,
|
||||
) -> Option<(PathBuf, Vec<ide_core::Diagnostic>)> {
|
||||
let docs: Vec<_> = self.injections.documents.read().values().cloned().collect();
|
||||
let host_path = if let Some(d) = docs.iter().find(|d| d.path == path) {
|
||||
d.host_path.clone()
|
||||
} else if reserved(path) {
|
||||
return None;
|
||||
} else {
|
||||
path.to_owned()
|
||||
};
|
||||
let conv = self.conv(enc);
|
||||
let native = if path == host_path { native } else { server.diagnostics_for(&host_path) };
|
||||
let mut items: Vec<_> =
|
||||
native.into_iter().map(|d| convert::diagnostic(&conv, &host_path, d, &server.name)).collect();
|
||||
for d in docs.iter().filter(|d| d.active && d.host_path == host_path) {
|
||||
items.extend(server.diagnostics_for(&d.path).into_iter().filter_map(|diag| {
|
||||
let mut diag = convert::diagnostic(&conv, &d.path, diag, &server.name);
|
||||
if !confined(d.injection.range, diag.range)
|
||||
|| (diag.range.is_empty() && !contains(d.injection.range, diag.range.start))
|
||||
{
|
||||
return None;
|
||||
}
|
||||
diag.source = Some(server.name.clone());
|
||||
Some(diag)
|
||||
}));
|
||||
}
|
||||
(!reserved(&host_path)).then_some((host_path, items))
|
||||
}
|
||||
}
|
||||
|
||||
fn uri_is_virtual(path: &Path) -> bool {
|
||||
ide_core::is_virtual_uri(&path.to_string_lossy())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
struct Missing;
|
||||
impl crate::Launcher for Missing {
|
||||
fn launch(
|
||||
&self,
|
||||
_: &ide_language::LanguageServerConfig,
|
||||
_: &Path,
|
||||
_: Arc<dyn ide_jsonrpc::Handler>,
|
||||
_: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> Result<ide_jsonrpc::Peer, crate::LaunchError> {
|
||||
Err(crate::LaunchError::Missing)
|
||||
}
|
||||
}
|
||||
|
||||
fn manager() -> (Arc<LspManager>, tempfile::TempDir, Arc<Mutex<Vec<ide_core::IdeEvent>>>) {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let output = events.clone();
|
||||
let mgr = LspManager::with_launcher(
|
||||
root.path().to_path_buf(),
|
||||
Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
Arc::new(move |e| output.lock().push(e)),
|
||||
Arc::new(Missing),
|
||||
);
|
||||
(mgr, root, events)
|
||||
}
|
||||
|
||||
fn injection(a: u32, b: u32) -> InjectedRange {
|
||||
InjectedRange { range: Range::new(Pos::new(0, a), Pos::new(0, b)), language: LanguageId::new("sql") }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_mapping_edits_and_limits() {
|
||||
let (mgr, root, _) = manager();
|
||||
let host = DocId::next();
|
||||
let path = root.path().join("a.java");
|
||||
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "😀 select tail");
|
||||
mgr.set_injections(host, 1, vec![injection(3, 9)]);
|
||||
let embedded = mgr.injected_document(host, Pos::new(0, 3));
|
||||
assert_ne!(host, embedded);
|
||||
assert_eq!(mgr.injected_document(host, Pos::new(0, 2)), host);
|
||||
assert_eq!(mgr.injected_document(host, Pos::new(0, 9)), host);
|
||||
let virtual_path = mgr.snapshot(embedded).unwrap().path;
|
||||
assert_eq!(virtual_path.parent(), path.parent());
|
||||
assert_eq!(virtual_path.extension().unwrap(), "sql");
|
||||
assert!(!virtual_path.exists());
|
||||
let loc = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 99)));
|
||||
let mapped = mgr.map_injected_location(loc).unwrap();
|
||||
assert_eq!(mapped.path.0, path);
|
||||
assert_eq!(mapped.range, injection(3, 9).range);
|
||||
let outside = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 2)));
|
||||
assert!(mgr.map_injected_location(outside).is_none());
|
||||
let real = root.path().join(".real.openidea-injected-123.sql");
|
||||
std::fs::write(&real, "real file").unwrap();
|
||||
let other = Location::file(real, Range::default());
|
||||
assert_eq!(mgr.map_injected_location(other.clone()), Some(other));
|
||||
let mut edit = WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Edit {
|
||||
path: virtual_path.clone().into(),
|
||||
edits: vec![
|
||||
ide_core::TextEdit { range: injection(3, 9).range, new_text: "SELECT".into() },
|
||||
ide_core::TextEdit { range: injection(0, 9).range, new_text: "bad".into() },
|
||||
],
|
||||
version: Some(999),
|
||||
},
|
||||
FileEdit::Create { path: virtual_path.clone().into(), overwrite: true },
|
||||
FileEdit::Rename { from: path.clone().into(), to: virtual_path.clone().into() },
|
||||
FileEdit::Delete { path: virtual_path.clone().into(), recursive: false },
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
mgr.map_injected_edit(&mut edit);
|
||||
assert_eq!(edit.files.len(), 1);
|
||||
assert!(
|
||||
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, version: Some(1) } if p.0 == path && edits.len() == 1)
|
||||
);
|
||||
mgr.set_injections(host, 0, vec![]);
|
||||
assert_ne!(mgr.injected_document(host, Pos::new(0, 5)), host, "stale refresh ignored");
|
||||
mgr.set_injections(host, 1, vec![]);
|
||||
assert_eq!(mgr.injected_document(host, Pos::new(0, 5)), host);
|
||||
assert!(mgr.map_injected_location(Location::file(virtual_path, injection(3, 9).range)).is_none());
|
||||
mgr.did_open(host, &path, &LanguageId::plain_text(), 2, &"a ".repeat(40));
|
||||
mgr.set_injections(host, 2, (0..40).map(|n| injection(n * 2, n * 2 + 1)).collect());
|
||||
assert_eq!(mgr.injections.documents.read().len(), 32);
|
||||
mgr.did_close(host);
|
||||
assert!(mgr.injections.documents.read().is_empty());
|
||||
mgr.did_open(host, &path, &LanguageId::plain_text(), 3, &"a".repeat(MAX_INJECTED_DOCUMENT_BYTES + 1));
|
||||
mgr.set_injections(host, 3, vec![injection(0, 1)]);
|
||||
assert!(mgr.injections.documents.read().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diagnostics_merge_fragments_native_source_and_clear() {
|
||||
let (mgr, root, events) = manager();
|
||||
let host = DocId::next();
|
||||
let path = root.path().join("a.java");
|
||||
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "xxsqlxxsql");
|
||||
mgr.set_injections(host, 1, vec![injection(2, 5), injection(7, 10)]);
|
||||
let a = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 2))).unwrap();
|
||||
let b = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 7))).unwrap();
|
||||
let server = a.servers[0].clone();
|
||||
let diag = |start, end, message: &str| lsp_types::Diagnostic {
|
||||
range: lsp_types::Range::new(lsp_types::Position::new(0, start), lsp_types::Position::new(0, end)),
|
||||
message: message.into(),
|
||||
source: Some("untrusted-source".into()),
|
||||
..Default::default()
|
||||
};
|
||||
server.set_diagnostics(path.clone(), vec![diag(0, 1, "native")]);
|
||||
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
|
||||
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
|
||||
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
|
||||
let latest = || {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
ide_core::IdeEvent::Diagnostics { path: p, source, items }
|
||||
if p.0 == path && source == &server.name =>
|
||||
{
|
||||
Some(items.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
|
||||
assert!(
|
||||
latest()
|
||||
.iter()
|
||||
.filter(|d| d.message != "native")
|
||||
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
|
||||
);
|
||||
mgr.set_injections(host, 1, vec![injection(7, 10)]);
|
||||
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
|
||||
mgr.set_injections(host, 1, vec![]);
|
||||
assert_eq!(latest()[0].message, "native");
|
||||
assert_eq!(latest().len(), 1);
|
||||
assert!(
|
||||
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_preserves_utf16_crlf_and_line_edges() {
|
||||
let text = "😀éSELECT\r\n中sql\r\nend";
|
||||
let range = Range::new(Pos::new(0, 3), Pos::new(1, 4));
|
||||
assert_eq!(mask(text, range), " SELECT\r\n中sql\r\n ");
|
||||
assert_eq!(mask(text, range).encode_utf16().count(), text.encode_utf16().count());
|
||||
assert_eq!(mask("a\nsql\nz", Range::new(Pos::new(1, 0), Pos::new(2, 0))), " \nsql\n ");
|
||||
assert_eq!(mask("sql", Range::new(Pos::new(0, 0), Pos::new(0, 3))), "sql");
|
||||
}
|
||||
#[test]
|
||||
fn boundaries_are_half_open_and_results_clip() {
|
||||
let r = Range::new(Pos::new(1, 3), Pos::new(2, 5));
|
||||
assert!(contains(r, r.start));
|
||||
assert!(!contains(r, r.end));
|
||||
assert!(!contains(r, Pos::new(1, 2)));
|
||||
assert_eq!(clip(r, Range::new(Pos::new(0, 0), Pos::new(3, 0))), Some(r));
|
||||
assert_eq!(clip(r, Range::point(r.end)), None);
|
||||
assert_eq!(clip(r, Range::new(Pos::new(0, 0), r.start)), None);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,33 @@ impl Launcher for ProcessLauncher {
|
||||
on_stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<(Peer, Option<PathBuf>), LaunchError> {
|
||||
if let Some(target) = &config.target {
|
||||
let argv =
|
||||
format!("argv: {:?}", std::iter::once(&config.command).chain(config.args.iter()).collect::<Vec<_>>());
|
||||
on_stderr(argv.clone());
|
||||
let binary = config
|
||||
.pinned
|
||||
.as_ref()
|
||||
.ok_or_else(|| LaunchError::Config("target wrapper has no executable pin".into()))?
|
||||
.verify()
|
||||
.map_err(LaunchError::Failed)?
|
||||
.to_owned();
|
||||
let env = config.env.iter().map(|(k, v)| (k.clone(), v.clone())).collect::<Vec<_>>();
|
||||
return Peer::spawn_transport(
|
||||
&binary.to_string_lossy(),
|
||||
&config.args,
|
||||
&target.project_root,
|
||||
&env,
|
||||
handler,
|
||||
on_stderr,
|
||||
ide_jsonrpc::TransportOptions {
|
||||
transform: Some(Arc::new(crate::target::TargetTransform(target.clone()))),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map(|peer| (peer, Some(binary)))
|
||||
.map_err(|e| LaunchError::Failed(format!("{argv}: {e}")));
|
||||
}
|
||||
let search = server_search_path(config);
|
||||
let binary = match &config.pinned {
|
||||
// A plugin's server: the approved program, unchanged since.
|
||||
@@ -125,6 +152,9 @@ impl Launcher for ProcessLauncher {
|
||||
}
|
||||
|
||||
fn available(&self, config: &LanguageServerConfig, root: &Path) -> bool {
|
||||
if config.target.is_some() {
|
||||
return true;
|
||||
}
|
||||
if let Some(pin) = &config.pinned {
|
||||
return pin.path.is_file();
|
||||
}
|
||||
|
||||
+231
-56
@@ -1,6 +1,6 @@
|
||||
//! Language server manager.
|
||||
//!
|
||||
//! Servers start lazily per `(server name, workspace root)` when a document of
|
||||
//! Servers start lazily per `(server name, workspace root, target)` when a document of
|
||||
//! a matching language opens. Several servers may serve one language (e.g.
|
||||
//! `vtsls` + `eslint`); results are merged. Every request takes positions in
|
||||
//! IDE coordinates (UTF-16, 0-based) and returns IDE types from [`types`],
|
||||
@@ -15,6 +15,7 @@ mod file_ops;
|
||||
mod file_watch;
|
||||
mod go_scope;
|
||||
mod handler;
|
||||
mod injections;
|
||||
mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
@@ -23,6 +24,7 @@ mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
mod target;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
@@ -31,6 +33,7 @@ pub mod types;
|
||||
mod uri;
|
||||
mod virtual_doc;
|
||||
|
||||
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
|
||||
pub use inspect::{FileInspection, InspectHost, InspectOptions};
|
||||
pub use launch::{LaunchError, Launcher, ProcessLauncher};
|
||||
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
|
||||
@@ -83,6 +86,10 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
|
||||
/// settings before each launch; receives the server's workspace root.
|
||||
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
|
||||
pub type ConfigHook = Arc<ConfigFn>;
|
||||
/// Returns true when target configuration replaces host toolchain configuration.
|
||||
pub type TargetConfigFn = dyn Fn(&LanguageId, &mut LanguageServerConfig) -> bool + Send + Sync;
|
||||
/// Fetches a bounded read-only container document after rechecking grants and trust.
|
||||
pub type TargetReadFn = dyn Fn(&ide_language::ServerTarget, &str) -> IdeResult<String> + Send + Sync;
|
||||
/// Prepares dependencies on a blocking worker immediately before launching an installed server.
|
||||
pub type PrepareFn = dyn Fn(&LanguageServerConfig, &Path, &CancellationToken) + Send + Sync;
|
||||
/// The servers a language uses, from its definition's list (user settings
|
||||
@@ -181,6 +188,8 @@ pub struct LspManager {
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
applied_edits: tokio::sync::broadcast::Sender<(ide_core::ServerId, WorkspaceEdit)>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
target_hook: RwLock<Option<Arc<TargetConfigFn>>>,
|
||||
target_read_hook: RwLock<Option<Arc<TargetReadFn>>>,
|
||||
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
@@ -196,6 +205,7 @@ pub struct LspManager {
|
||||
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
|
||||
/// Contents fetched for virtual URIs.
|
||||
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
|
||||
injections: injections::State,
|
||||
}
|
||||
|
||||
/// Fetched virtual documents kept before the cache is reset.
|
||||
@@ -361,12 +371,23 @@ impl LspManager {
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
) -> Arc<Self> {
|
||||
Arc::new_cyclic(|me| Self {
|
||||
Arc::<Self>::new_cyclic(|me| Self {
|
||||
me: me.clone(),
|
||||
project_root,
|
||||
content_roots: RwLock::new(Vec::new()),
|
||||
languages,
|
||||
sink,
|
||||
sink: {
|
||||
let me = me.clone();
|
||||
Arc::new(move |event| {
|
||||
if let Some(mgr) = me.upgrade() {
|
||||
if let Some(event) = mgr.map_injected_event(event) {
|
||||
sink.emit(event);
|
||||
}
|
||||
} else {
|
||||
sink.emit(event);
|
||||
}
|
||||
})
|
||||
},
|
||||
launcher,
|
||||
runtime: runtime_handle(),
|
||||
servers: Mutex::new(Vec::new()),
|
||||
@@ -374,6 +395,8 @@ impl LspManager {
|
||||
apply_edit_hook: RwLock::new(None),
|
||||
applied_edits: tokio::sync::broadcast::channel(64).0,
|
||||
config_hook: RwLock::new(None),
|
||||
target_hook: RwLock::new(None),
|
||||
target_read_hook: RwLock::new(None),
|
||||
prepare_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
@@ -386,6 +409,7 @@ impl LspManager {
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
injections: injections::State::default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -412,12 +436,14 @@ impl LspManager {
|
||||
// Outside the project (library sources, …): reuse a running instance.
|
||||
servers
|
||||
.iter()
|
||||
.filter(|s| s.name == cfg.name)
|
||||
.filter(|s| s.name == cfg.name && s.config.target_key == cfg.target_key)
|
||||
.min_by_key(|s| s.root != self.project_root)
|
||||
.map(|s| s.root.clone())
|
||||
.unwrap_or_else(|| self.project_root.clone())
|
||||
});
|
||||
if let Some(s) = servers.iter().find(|s| s.name == cfg.name && s.root == root) {
|
||||
if let Some(s) =
|
||||
servers.iter().find(|s| s.name == cfg.name && s.root == root && s.config.target_key == cfg.target_key)
|
||||
{
|
||||
s.add_language(language);
|
||||
return s.clone();
|
||||
}
|
||||
@@ -464,8 +490,13 @@ impl LspManager {
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> = self
|
||||
.docs
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(_, d)| !d.uri.as_str().starts_with("openidea-target:"))
|
||||
.map(|(id, d)| (*id, d.language.clone(), d.path.clone()))
|
||||
.collect();
|
||||
let wanted: Vec<(DocId, Vec<Arc<Server>>)> =
|
||||
open.into_iter().map(|(id, language, path)| (id, self.servers_for(&language, &path))).collect();
|
||||
let mut start = Vec::new();
|
||||
@@ -542,7 +573,8 @@ impl LspManager {
|
||||
}
|
||||
tail.set_effective_config(generation, cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
if cfg.target.is_none()
|
||||
&& !cancel.is_cancelled()
|
||||
&& me.launcher.available(&cfg, &tail.root)
|
||||
&& let Some(prepare) = prepare
|
||||
{
|
||||
@@ -553,7 +585,7 @@ impl LspManager {
|
||||
let root = tail.root.clone();
|
||||
if let Some(message) = &cfg.launch_error {
|
||||
// A missing binary still asks to be installed first.
|
||||
let missing = !me.launcher.available(&cfg, &root);
|
||||
let missing = cfg.target.is_none() && !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
@@ -812,22 +844,27 @@ impl LspManager {
|
||||
|
||||
pub(crate) fn conv(&self, enc: Encoding) -> Conv<'_> {
|
||||
Conv::new(enc, move |p: &Path| self.text_for_path(p))
|
||||
.with_injections(move |loc| self.map_injected_location(loc), move |edit| self.map_injected_edit(edit))
|
||||
}
|
||||
|
||||
// ---- document sync (driven by ide-workspace's DocumentObserver) ----
|
||||
pub fn did_open(&self, doc: DocId, path: &Path, language: &LanguageId, version: u32, text: &str) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.did_close(doc);
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
let servers = self.servers_for(language, path);
|
||||
self.attach(doc, path.to_owned(), uri::path_to_uri(path), language, version, text, servers);
|
||||
self.schedule_injections(doc);
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (`jdt://…`) on the server that
|
||||
/// returned its URI (`didOpen` only: it is never changed or saved).
|
||||
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
|
||||
if self.docs.lock().contains_key(&doc) {
|
||||
self.did_close(doc);
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
let Ok(lsp_uri) = uri.parse::<lsp::Uri>() else {
|
||||
tracing::debug!("not a valid URI: {uri}");
|
||||
@@ -887,6 +924,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub fn did_change(&self, doc: DocId, version: u32, changes: &[AppliedChange], full_text: &dyn Fn() -> String) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
let mut docs = self.docs.lock();
|
||||
let Some(d) = docs.get_mut(&doc) else { return };
|
||||
d.version = version as i32;
|
||||
@@ -929,9 +967,6 @@ impl LspManager {
|
||||
tracing::warn!("lsp text copy of {} out of sync; resending full text", d.path.display());
|
||||
text.set(&full_text());
|
||||
}
|
||||
if targets.is_empty() {
|
||||
return;
|
||||
}
|
||||
let full = json!([{ "text": text.as_str() }]);
|
||||
for (server, run, sync, enc) in targets {
|
||||
let key = match enc {
|
||||
@@ -954,6 +989,8 @@ impl LspManager {
|
||||
}
|
||||
drop(docs);
|
||||
self.schedule_pull(doc, None, pull::AFTER_EDIT);
|
||||
self.invalidate_injections(doc);
|
||||
self.schedule_injections(doc);
|
||||
}
|
||||
|
||||
/// Sends the documents open on instance `generation` of `server` again, as a `didChange`
|
||||
@@ -1004,6 +1041,12 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub fn did_close(&self, doc: DocId) {
|
||||
let _gate = self.injections.gate.lock();
|
||||
self.close_injections(doc);
|
||||
self.close_document(doc);
|
||||
}
|
||||
|
||||
fn close_document(&self, doc: DocId) {
|
||||
let Some(d) = self.docs.lock().remove(&doc) else { return };
|
||||
for a in &d.servers {
|
||||
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
|
||||
@@ -1067,19 +1110,25 @@ impl LspManager {
|
||||
cancel: Option<&CancellationToken>,
|
||||
timeout: Duration,
|
||||
) -> IdeResult<R> {
|
||||
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
let stamp = self.injected_request_stamp(¶ms);
|
||||
let fut = async {
|
||||
if t.server.name == "csharp-ls" {
|
||||
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
|
||||
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
|
||||
virtual_doc::normalize_csharp_uris(&mut value);
|
||||
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
|
||||
} else {
|
||||
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
|
||||
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
|
||||
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
|
||||
};
|
||||
if let Some((doc, version)) = stamp {
|
||||
self.check_injected_request(doc, version)?;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
// ---- features ----
|
||||
@@ -1114,8 +1163,10 @@ impl LspManager {
|
||||
let conv = self.conv(t.enc());
|
||||
let list = out.value.get_or_insert(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.is_incomplete |= incomplete;
|
||||
list.items
|
||||
.extend(items.into_iter().map(|i| convert::completion_item(&conv, &snap.path, i, t.server.id)));
|
||||
list.items.extend(items.into_iter().filter_map(|i| {
|
||||
let mut item = convert::completion_item(&conv, &snap.path, i, t.server.id);
|
||||
self.bound_completion(doc, &mut item).then_some(item)
|
||||
}));
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
}
|
||||
@@ -1157,21 +1208,33 @@ impl LspManager {
|
||||
out.finish(Vec::new())
|
||||
}
|
||||
|
||||
pub async fn resolve_completion(&self, doc: DocId, item: CompletionItem) -> IdeResult<CompletionItem> {
|
||||
pub async fn resolve_completion(&self, doc: DocId, mut item: CompletionItem) -> IdeResult<CompletionItem> {
|
||||
let original = item.clone();
|
||||
let host_doc = doc;
|
||||
let doc = self.resolve_injected_origin(doc, &mut item.data)?;
|
||||
let snap = self.snapshot(doc)?;
|
||||
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(item) };
|
||||
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(original) };
|
||||
let Some(t) = self
|
||||
.ready_targets(std::slice::from_ref(server), |c| c.has_sub("completionProvider", "resolveProvider"))
|
||||
.await
|
||||
.pop()
|
||||
else {
|
||||
return Ok(item);
|
||||
return Ok(original);
|
||||
};
|
||||
if let Some(command) = item.command.take() {
|
||||
item.command = Some(self.injected_command(doc, command)?.1);
|
||||
}
|
||||
let lsp_item = convert::lsp_completion_item(&snap.text, t.enc(), &item);
|
||||
let resolved: lsp::CompletionItem =
|
||||
self.call(&t, "completionItem/resolve", lsp_item, None, DEFAULT_TIMEOUT).await?;
|
||||
if doc != host_doc {
|
||||
self.check_injected_request(doc, snap.version)?;
|
||||
}
|
||||
let mut out = item;
|
||||
convert::overlay_completion(&self.conv(t.enc()), &snap.path, &mut out, resolved, false);
|
||||
if !self.bound_completion(doc, &mut out) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
@@ -1188,7 +1251,11 @@ impl LspManager {
|
||||
// Still loading: no documentation yet, not an error.
|
||||
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
|
||||
Ok(Some(h)) => {
|
||||
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
if let Some(mut h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
|
||||
if let Some(range) = h.range {
|
||||
let Some(range) = self.clip_injected_range(doc, range) else { continue };
|
||||
h.range = Some(range);
|
||||
}
|
||||
return Ok(Some(h));
|
||||
}
|
||||
out.value = Some(None);
|
||||
@@ -1395,8 +1462,8 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
|
||||
/// workspace's sources are searched (`libraries_skipped`); with it, those found
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources
|
||||
/// are searched (`libraries_skipped`); with it and `project_wide`, those found
|
||||
/// there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
@@ -1563,7 +1630,10 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
let first = query(false).await?;
|
||||
// Scoped probes (usage counts, bulk scans) run for many symbols at once:
|
||||
// without `libraries`, the dependencies and the JDK are not searched.
|
||||
libraries_skipped = !libraries;
|
||||
let first = query(!libraries).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
@@ -1697,7 +1767,14 @@ impl LspManager {
|
||||
match r {
|
||||
Ok(Some(hs)) if !hs.is_empty() => {
|
||||
let conv = self.conv(t.enc());
|
||||
return Ok(hs.into_iter().map(|h| convert::highlight(&conv, &snap.path, h)).collect());
|
||||
return Ok(hs
|
||||
.into_iter()
|
||||
.filter_map(|h| {
|
||||
let mut h = convert::highlight(&conv, &snap.path, h);
|
||||
h.range = self.clip_injected_range(doc, h.range)?;
|
||||
Some(h)
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
Ok(_) => out.value = Some(Vec::new()),
|
||||
Err(e) => out.fail(e),
|
||||
@@ -1848,7 +1925,12 @@ impl LspManager {
|
||||
actions
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| convert::code_action(&conv, a, t.server.id))
|
||||
.map(|a| {
|
||||
let mut action = convert::code_action(&conv, a, t.server.id);
|
||||
self.tag_injected_origin(doc, &mut action.data);
|
||||
self.tag_injected_command(doc, action.server, &mut action.command);
|
||||
action
|
||||
})
|
||||
// Servers may ignore `only`, and the kinds kept for this one.
|
||||
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
|
||||
);
|
||||
@@ -1859,7 +1941,10 @@ impl LspManager {
|
||||
out.finish(Vec::new()).map(|actions| CodeActionQuery { actions, advertised_kinds })
|
||||
}
|
||||
|
||||
pub async fn resolve_code_action(&self, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
pub async fn resolve_code_action(&self, doc: DocId, mut action: CodeAction) -> IdeResult<CodeAction> {
|
||||
let original = action.clone();
|
||||
let host_doc = doc;
|
||||
let doc = self.resolve_injected_origin(doc, &mut action.data)?;
|
||||
let snap = self.snapshot(doc)?;
|
||||
let servers: Vec<Arc<Server>> = match snap.servers.iter().find(|s| s.id == action.server) {
|
||||
Some(s) => vec![s.clone()],
|
||||
@@ -1867,19 +1952,33 @@ impl LspManager {
|
||||
};
|
||||
let Some(t) = self.ready_targets(&servers, |c| c.has_sub("codeActionProvider", "resolveProvider")).await.pop()
|
||||
else {
|
||||
return Ok(action);
|
||||
return Ok(original);
|
||||
};
|
||||
if let Some(command) = action.command.take() {
|
||||
action.command = Some(self.injected_command(doc, command)?.1);
|
||||
}
|
||||
let resolved: lsp::CodeAction =
|
||||
self.call(&t, "codeAction/resolve", convert::lsp_code_action(&action), None, DEFAULT_TIMEOUT).await?;
|
||||
Ok(convert::merge_code_action(&self.conv(t.enc()), action, resolved))
|
||||
if doc != host_doc {
|
||||
self.check_injected_request(doc, snap.version)?;
|
||||
}
|
||||
let mut action = convert::merge_code_action(&self.conv(t.enc()), action, resolved);
|
||||
self.tag_injected_origin(doc, &mut action.data);
|
||||
self.tag_injected_command(doc, action.server, &mut action.command);
|
||||
Ok(action)
|
||||
}
|
||||
|
||||
pub async fn execute_command(&self, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if command.command == injections::COMMAND {
|
||||
self.refresh_injections(doc).await?;
|
||||
}
|
||||
let (doc, command, server) = self.injected_command(doc, command)?;
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
};
|
||||
let name = command.command.clone();
|
||||
let servers: Vec<_> = servers.into_iter().filter(|s| server.is_none_or(|id| s.id == id)).collect();
|
||||
let targets = self.ready_targets(&servers, |c| c.commands().contains(&name)).await;
|
||||
let t = targets
|
||||
.first()
|
||||
@@ -1893,6 +1992,9 @@ impl LspManager {
|
||||
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
|
||||
/// `_typescript.didOrganizeImports`): no server provides those.
|
||||
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
|
||||
if name == injections::COMMAND {
|
||||
return self.provides_injected_command(doc, server);
|
||||
}
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
@@ -1921,6 +2023,10 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
let (doc, command, pinned) = self.injected_command(doc, command)?;
|
||||
if pinned.is_some_and(|id| id != server) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Nothing to ask for a command its server doesn't provide (executing it decides).
|
||||
if !self.provides_command(doc, Some(server), &command.command).await {
|
||||
return Ok(None);
|
||||
@@ -1944,17 +2050,19 @@ impl LspManager {
|
||||
return Err(IdeError::unavailable("unsupported command input type"));
|
||||
}
|
||||
}
|
||||
let mut command = Some(Command {
|
||||
title: command.title,
|
||||
command: response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::internal("command form omitted command"))?
|
||||
.to_owned(),
|
||||
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
|
||||
.map_err(parse_error)?,
|
||||
});
|
||||
self.tag_injected_command(doc, server, &mut command);
|
||||
Ok(Some(CommandForm {
|
||||
command: Command {
|
||||
title: command.title,
|
||||
command: response
|
||||
.get("command")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| IdeError::internal("command form omitted command"))?
|
||||
.to_owned(),
|
||||
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
|
||||
.map_err(parse_error)?,
|
||||
},
|
||||
command: command.expect("resolved command"),
|
||||
form_fields: serde_json::from_value(fields).map_err(parse_error)?,
|
||||
form_answers: serde_json::from_value(
|
||||
response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])),
|
||||
@@ -1972,6 +2080,13 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
if command.command == injections::COMMAND {
|
||||
self.refresh_injections(doc).await?;
|
||||
}
|
||||
let (doc, command, pinned) = self.injected_command(doc, command)?;
|
||||
if pinned.is_some_and(|id| id != server) {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let open_created = t.server.name == "gopls" && command.command == "gopls.add_test";
|
||||
let mut edits = self.applied_edits.subscribe();
|
||||
@@ -2670,8 +2785,15 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn origin_server(&self, uri: &str) -> Option<Arc<Server>> {
|
||||
let id = *self.virtual_origins.lock().get(uri)?;
|
||||
self.servers.lock().iter().find(|s| s.id == id).cloned()
|
||||
let id = self.virtual_origins.lock().get(uri).copied();
|
||||
self.servers
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|s| {
|
||||
Some(s.id) == id
|
||||
|| s.effective_config().target.as_ref().is_some_and(|t| target::external_path(t, uri).is_some())
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Contents of a virtual document, from the server that owns `uri`:
|
||||
@@ -2680,6 +2802,29 @@ impl LspManager {
|
||||
/// is read from its archive, a `jar:` class shown as its library source
|
||||
/// (see [`library_source`]) or else decompiled by the server. Cached.
|
||||
pub async fn virtual_document(&self, uri: &str) -> IdeResult<VirtualDocument> {
|
||||
if uri.starts_with("openidea-target:") {
|
||||
let server = self
|
||||
.origin_server(uri)
|
||||
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
|
||||
let target = server
|
||||
.effective_config()
|
||||
.target
|
||||
.ok_or_else(|| IdeError::unavailable("container language server is no longer available"))?;
|
||||
let path = target::external_path(&target, uri)
|
||||
.ok_or_else(|| IdeError::invalid("invalid container document URI"))?;
|
||||
let read = self
|
||||
.target_read_hook
|
||||
.read()
|
||||
.clone()
|
||||
.ok_or_else(|| IdeError::unavailable("container document reader is unavailable"))?;
|
||||
let languages = server.languages();
|
||||
let title = Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned());
|
||||
let description = Some(path.clone());
|
||||
let text = tokio::task::spawn_blocking(move || read(&target, &path))
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(e.to_string()))??;
|
||||
return Ok(self.store_virtual(uri, text, title, description, &languages, Some(server.id)));
|
||||
}
|
||||
if let Some(d) = self.virtual_docs.lock().get(uri) {
|
||||
return Ok(d.clone());
|
||||
}
|
||||
@@ -2834,6 +2979,11 @@ impl LspManager {
|
||||
|
||||
/// Called when a server sends `workspace/applyEdit`; installed by `src-tauri`
|
||||
/// so edits flow through `ide-workspace::Documents`.
|
||||
pub fn set_target_hooks(&self, configure: Box<TargetConfigFn>, read: Box<TargetReadFn>) {
|
||||
*self.target_hook.write() = Some(Arc::from(configure));
|
||||
*self.target_read_hook.write() = Some(Arc::from(read));
|
||||
}
|
||||
|
||||
pub fn set_config_hook(&self, hook: Box<ConfigFn>) {
|
||||
*self.config_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
@@ -2854,9 +3004,23 @@ impl LspManager {
|
||||
cfg.launch_error = Some("Project is in Restricted Mode".into());
|
||||
return cfg;
|
||||
}
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
let in_target = self.target_hook.read().clone().is_some_and(|hook| {
|
||||
server
|
||||
.languages()
|
||||
.iter()
|
||||
.filter(|language| {
|
||||
self.language_servers(language)
|
||||
.iter()
|
||||
.any(|c| c.name == server.name && c.target_key == server.config.target_key)
|
||||
})
|
||||
.any(|language| hook(language, &mut cfg))
|
||||
});
|
||||
if !in_target && let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
if let Some(target) = &mut cfg.target {
|
||||
target.origin = Some(server.id);
|
||||
}
|
||||
cfg
|
||||
}
|
||||
|
||||
@@ -2877,6 +3041,9 @@ impl LspManager {
|
||||
self.ensure_started(server);
|
||||
}
|
||||
}
|
||||
if allowed != previous {
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers).
|
||||
@@ -2924,6 +3091,11 @@ impl LspManager {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if next.target != current.target {
|
||||
for doc in self.docs.lock().values_mut().filter(|d| d.uri.as_str().starts_with("openidea-target:")) {
|
||||
doc.servers.retain(|a| a.server.id != s.id);
|
||||
}
|
||||
}
|
||||
match s.status() {
|
||||
ide_core::event::ServerStatus::Ready
|
||||
| ide_core::event::ServerStatus::Busy
|
||||
@@ -2944,12 +3116,13 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
self.detach_shadowed_servers().await;
|
||||
self.injection_providers_changed();
|
||||
}
|
||||
|
||||
/// A server enabled again shadows the fallbacks attached in its place: they
|
||||
/// leave the open documents, and stop once no document uses them.
|
||||
async fn detach_shadowed_fallbacks(&self) {
|
||||
/// Detaches fallbacks shadowed by enabled servers and instances whose target
|
||||
/// no longer matches the document's language. Stop them once unused.
|
||||
async fn detach_shadowed_servers(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
|
||||
@@ -2963,7 +3136,10 @@ impl LspManager {
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
&& (configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
|| !configs
|
||||
.iter()
|
||||
.any(|c| c.name == a.server.name && c.target_key == a.server.config.target_key))
|
||||
})
|
||||
.map(|a| a.server.id)
|
||||
.collect();
|
||||
@@ -3019,7 +3195,11 @@ impl LspManager {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
let unlisted: Vec<Arc<Server>> = servers
|
||||
.into_iter()
|
||||
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
|
||||
.filter(|s| {
|
||||
!s.languages().iter().any(|l| {
|
||||
self.language_servers(l).iter().any(|c| c.name == s.name && c.target_key == s.config.target_key)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
if unlisted.is_empty() {
|
||||
return;
|
||||
@@ -3053,13 +3233,8 @@ impl LspManager {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for server in servers {
|
||||
let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
|
||||
let conv = self.conv(encoding);
|
||||
for (path, items) in server.diagnostics_by_path() {
|
||||
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
|
||||
server.emit(
|
||||
&self.sink,
|
||||
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
|
||||
);
|
||||
self.emit_diagnostics(&server, path, items, encoding);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! and shown together.
|
||||
|
||||
use crate::server::{Running, Server};
|
||||
use crate::{LspManager, convert, virtual_doc};
|
||||
use crate::{LspManager, virtual_doc};
|
||||
use ide_core::{DocId, IdeEvent};
|
||||
use ide_jsonrpc::RpcError;
|
||||
use lsp_types as lsp;
|
||||
@@ -189,8 +189,8 @@ impl LspManager {
|
||||
items: Vec<lsp::Diagnostic>,
|
||||
enc: crate::text::Encoding,
|
||||
) {
|
||||
let conv = self.conv(enc);
|
||||
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
|
||||
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
if let Some((path, items)) = self.injected_diagnostics(server, &path, enc, items) {
|
||||
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-14
@@ -25,12 +25,17 @@ use std::time::Duration;
|
||||
|
||||
/// Files opened for one search at most.
|
||||
pub const MAX_CANDIDATES: usize = 300;
|
||||
/// Candidates checked against a server's analysis scope ([`COVERED_FILES`]) at most: most
|
||||
/// are usually covered, so a common name only exceeds [`MAX_CANDIDATES`] after that check.
|
||||
const MAX_SCOPE_CANDIDATES: usize = 5000;
|
||||
/// Larger files (generated code, bundles) are not opened.
|
||||
const MAX_CANDIDATE_BYTES: u64 = 1 << 20;
|
||||
/// How long a search that found nothing waits for a server to finish indexing.
|
||||
const INDEX_WAIT: Duration = Duration::from_secs(10);
|
||||
/// Optional server command: candidate URIs in, already analysed candidate URIs out.
|
||||
const COVERED_FILES: &str = "openidea.workspace.coveredFiles";
|
||||
/// Optional server command: directories whose files of its languages it all analyses.
|
||||
const COVERED_ROOTS: &str = "openidea.workspace.coveredRoots";
|
||||
|
||||
/// Project files that may mention a symbol.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -42,6 +47,8 @@ pub struct CandidateQuery {
|
||||
/// Only files of these languages.
|
||||
pub languages: Vec<LanguageId>,
|
||||
pub limit: usize,
|
||||
/// Directories not to look in: the server already analyses every file there.
|
||||
pub skip: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
@@ -92,14 +99,16 @@ impl LspManager {
|
||||
*self.candidates_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
async fn candidates(&self, t: &Target, word: String, cancel: &CancellationToken) -> Option<CandidateFiles> {
|
||||
async fn candidates(
|
||||
&self,
|
||||
t: &Target,
|
||||
word: String,
|
||||
limit: usize,
|
||||
skip: Vec<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Option<CandidateFiles> {
|
||||
let hook = self.candidates_hook.read().clone()?;
|
||||
let query = CandidateQuery {
|
||||
word,
|
||||
root: t.server.root.clone(),
|
||||
languages: t.server.languages(),
|
||||
limit: MAX_CANDIDATES,
|
||||
};
|
||||
let query = CandidateQuery { word, root: t.server.root.clone(), languages: t.server.languages(), limit, skip };
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || hook(&query, &cancel)).await.ok()
|
||||
}
|
||||
@@ -125,11 +134,12 @@ impl LspManager {
|
||||
// gopls searches the view of the file asked about; files opened for it only get views of
|
||||
// their own (see `go_scope`).
|
||||
let word = search_word(snap, pos).filter(|_| t.caps().open_close && snap.language.as_str() != "go");
|
||||
let reports_scope = has_command(t, COVERED_FILES);
|
||||
let limit = if reports_scope { MAX_SCOPE_CANDIDATES } else { MAX_CANDIDATES };
|
||||
let candidates = async {
|
||||
match word {
|
||||
Some(w) => self.candidates(t, w, cancel).await,
|
||||
None => None,
|
||||
}
|
||||
let w = word?;
|
||||
let skip = if reports_scope { self.covered_roots(t, cancel).await } else { Vec::new() };
|
||||
self.candidates(t, w, limit, skip, cancel).await
|
||||
};
|
||||
let (first, candidates) = futures::join!(query(), candidates);
|
||||
let mut first = first?;
|
||||
@@ -148,10 +158,14 @@ impl LspManager {
|
||||
}
|
||||
let known: HashSet<PathBuf> = covered(&first).into_iter().chain([snap.path.clone()]).collect();
|
||||
let files: Vec<PathBuf> = candidates.files.into_iter().filter(|f| !known.contains(f)).collect();
|
||||
let files = self.files_beyond_scope(t, files, cancel).await;
|
||||
let mut files = self.files_beyond_scope(t, files, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if files.len() > MAX_CANDIDATES {
|
||||
files.truncate(MAX_CANDIDATES);
|
||||
incomplete = incomplete.or(Some(ReferencesIncomplete::TooManyFiles));
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Ok(Widened { first, second: None, incomplete });
|
||||
}
|
||||
@@ -180,8 +194,25 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories whose files `t` analyses all ([`COVERED_ROOTS`]); none when it can't say.
|
||||
async fn covered_roots(&self, t: &Target, cancel: &CancellationToken) -> Vec<PathBuf> {
|
||||
if !has_command(t, COVERED_ROOTS) {
|
||||
return Vec::new();
|
||||
}
|
||||
let params = json!({ "command": COVERED_ROOTS, "arguments": [] });
|
||||
let roots: IdeResult<Vec<String>> =
|
||||
self.call(t, "workspace/executeCommand", params, Some(cancel), crate::DEFAULT_TIMEOUT).await;
|
||||
match roots {
|
||||
Ok(roots) => roots.iter().filter_map(|uri| crate::uri::uri_to_path(uri)).collect(),
|
||||
Err(e) => {
|
||||
tracing::debug!("{}: covered roots unavailable: {}", t.server.name, e.message);
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn files_beyond_scope(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> Vec<PathBuf> {
|
||||
if files.is_empty() || !t.caps().commands().iter().any(|c| c == COVERED_FILES) {
|
||||
if files.is_empty() || !has_command(t, COVERED_FILES) {
|
||||
return files;
|
||||
}
|
||||
let uris: Vec<String> = files.iter().map(|p| crate::uri::path_to_uri_string(p)).collect();
|
||||
@@ -224,7 +255,9 @@ impl LspManager {
|
||||
.filter(|l| l.uri.is_none())
|
||||
.collect();
|
||||
let Some(declaration) = declarations.first() else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), cancel).await else { return GoMentions::default() };
|
||||
let Some(candidates) = self.candidates(t, word.clone(), MAX_CANDIDATES, Vec::new(), cancel).await else {
|
||||
return GoMentions::default();
|
||||
};
|
||||
let (origin, declared) = (snap.path.clone(), declaration.path.to_path_buf());
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
crate::go_scope::files_beyond_search(&origin, &declared, candidates.files)
|
||||
@@ -478,3 +511,7 @@ impl Drop for TransientFiles<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_command(t: &Target, command: &str) -> bool {
|
||||
t.caps().commands().iter().any(|c| c == command)
|
||||
}
|
||||
@@ -368,6 +368,12 @@ impl Server {
|
||||
message.push_str(":\n");
|
||||
message.push_str(&tail);
|
||||
}
|
||||
if cfg.target.is_some() {
|
||||
message.push_str(&format!(
|
||||
"\nargv: {:?}",
|
||||
std::iter::once(&cfg.command).chain(cfg.args.iter()).collect::<Vec<_>>()
|
||||
));
|
||||
}
|
||||
let delay = self.record_crash();
|
||||
message.push_str(if delay.is_some() {
|
||||
"\nRestarting…"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Protocol mapping for a stdio server inside a plugin target.
|
||||
use ide_core::path_mapping::{Direction, file_path, file_uri, rewrite_strings};
|
||||
use ide_jsonrpc::MessageTransform;
|
||||
use ide_language::ServerTarget;
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
|
||||
pub(crate) fn virtual_prefix(target: &ServerTarget) -> String {
|
||||
format!(
|
||||
"openidea-target://{}/{}/",
|
||||
utf8_percent_encode(&target.id, NON_ALPHANUMERIC),
|
||||
target.origin.map_or(0, |id| id.0)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn external_path(target: &ServerTarget, uri: &str) -> Option<String> {
|
||||
let path = file_path(&format!("file:///{}", uri.strip_prefix(&virtual_prefix(target))?))?;
|
||||
(path.starts_with('/')
|
||||
&& !path.split('/').any(|s| s == ".." || s == ".")
|
||||
&& target.mapping.map(&file_uri(&path), Direction::ToHost).is_none())
|
||||
.then_some(path)
|
||||
}
|
||||
|
||||
fn suffix(uri: &str) -> &str {
|
||||
uri.find(['?', '#']).map(|i| &uri[i..]).unwrap_or("")
|
||||
}
|
||||
|
||||
pub(crate) struct TargetTransform(pub ServerTarget);
|
||||
|
||||
impl MessageTransform for TargetTransform {
|
||||
fn outgoing(&self, value: &mut Value) {
|
||||
rewrite_strings(value, &|s| {
|
||||
// A container library path may happen to have the host root's spelling.
|
||||
external_path(&self.0, s)
|
||||
.map(|p| format!("{}{}", file_uri(&p), suffix(s)))
|
||||
.or_else(|| self.0.mapping.map(s, Direction::ToTarget))
|
||||
});
|
||||
}
|
||||
|
||||
fn incoming(&self, value: &mut Value) {
|
||||
rewrite_strings(value, &|s| {
|
||||
if let Some(mapped) = self.0.mapping.map(s, Direction::ToHost) {
|
||||
return Some(mapped);
|
||||
}
|
||||
let path = file_path(s)?;
|
||||
if !path.starts_with('/') || path.split('/').any(|p| p == ".." || p == ".") {
|
||||
return None;
|
||||
}
|
||||
let uri = file_uri(&path);
|
||||
Some(format!("{}{}{}", virtual_prefix(&self.0), uri.strip_prefix("file:///")?, suffix(s)))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::path_mapping::PathMapping;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn external_uris_are_virtual_but_plain_outside_paths_and_opaque_values_stay_intact() {
|
||||
let target = ServerTarget {
|
||||
id: "plugin-target:example".into(),
|
||||
origin: Some(ide_core::ServerId(7)),
|
||||
project_root: "/host/project".into(),
|
||||
container_root: "/container".into(),
|
||||
mapping: PathMapping::new("/host/project", "/container"),
|
||||
};
|
||||
let mapper = TargetTransform(target.clone());
|
||||
let mut value = json!({"uri":"file:///container/a%20b.php", "relatedInformation":[{"location":{"uri":"file:///usr/lib/a%20b.php"}}],
|
||||
"data":{"uri":"file:///container/a.php"}, "command":{"arguments":["/container/a.php"]}, "path":"/usr/lib/a.php", "link":"file:///usr/lib/a.php#L12", "alias":"file:///host/project/std.php"});
|
||||
mapper.incoming(&mut value);
|
||||
assert_eq!(value["uri"], "file:///host/project/a%20b.php");
|
||||
let external = value["relatedInformation"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert!(external.starts_with("openidea-target:"));
|
||||
assert_eq!(external_path(&target, external).unwrap(), "/usr/lib/a b.php");
|
||||
let other = ServerTarget { origin: Some(ide_core::ServerId(8)), ..target.clone() };
|
||||
assert!(external_path(&other, external).is_none());
|
||||
assert!(value["link"].as_str().unwrap().ends_with("#L12"));
|
||||
assert_eq!(value["path"], "/usr/lib/a.php");
|
||||
assert_eq!(value["data"]["uri"], "file:///container/a.php");
|
||||
assert_eq!(value["command"]["arguments"][0], "/container/a.php");
|
||||
mapper.outgoing(&mut value);
|
||||
assert_eq!(value["uri"], "file:///container/a%20b.php");
|
||||
assert_eq!(value["relatedInformation"][0]["location"]["uri"], "file:///usr/lib/a%20b.php");
|
||||
assert_eq!(value["link"], "file:///usr/lib/a.php#L12");
|
||||
assert_eq!(value["alias"], "file:///host/project/std.php");
|
||||
let mut configuration =
|
||||
json!({"result":[{"path":"/host/project/vendor","uris":["file:///host/project/vendor/a.php"]}]});
|
||||
mapper.outgoing(&mut configuration);
|
||||
assert_eq!(configuration["result"][0]["path"], "/container/vendor");
|
||||
assert_eq!(configuration["result"][0]["uris"][0], "file:///container/vendor/a.php");
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,6 @@ impl DocText {
|
||||
&self.text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn line_count(&self) -> usize {
|
||||
self.lines.len()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
//! Protocol tests against in-memory mock language servers.
|
||||
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::{DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit};
|
||||
use ide_core::{
|
||||
CancellationToken, DocId, FileEdit, IdeEvent, LanguageId, Pos, Range, ServerId, Severity, WorkspaceEdit,
|
||||
};
|
||||
use ide_jsonrpc::{Handler, NoopHandler, Peer};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
|
||||
use ide_lsp::types::*;
|
||||
@@ -241,6 +243,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3450,6 +3454,81 @@ async fn unavailable_scope_capability_keeps_widening_and_incomplete_signals() {
|
||||
}
|
||||
}
|
||||
|
||||
/// A common name is mentioned by more files than a search opens, but a server that reports its
|
||||
/// analysis scope already covers most of them: only those beyond it count toward the limit.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn covered_candidates_do_not_count_toward_the_open_limit() {
|
||||
for covers_all in [true, false] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = json!(["openidea.workspace.coveredFiles"]);
|
||||
let h = harness(&[("mock", caps)]);
|
||||
let s = h.launcher.state("mock");
|
||||
let files: Vec<PathBuf> = (0..ide_lsp::MAX_CANDIDATES + 100)
|
||||
.map(|i| {
|
||||
let path = h.root.join(format!("f{i:03}.mk"));
|
||||
std::fs::write(&path, "target\n").unwrap();
|
||||
path
|
||||
})
|
||||
.collect();
|
||||
let covered: Vec<String> = if covers_all { files.iter().map(|f| uri_of(f)).collect() } else { vec![] };
|
||||
let responder = open_files_only(vec![]);
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
if method == "workspace/executeCommand" {
|
||||
return Some(json!(covered));
|
||||
}
|
||||
responder(s, method, params)
|
||||
}));
|
||||
let seen = grep_candidates(&h);
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
assert!(seen.lock().iter().all(|q| q.limit > ide_lsp::MAX_CANDIDATES));
|
||||
let opened = s.notified("textDocument/didOpen").len() - 1;
|
||||
if covers_all {
|
||||
assert_eq!(refs.incomplete, None);
|
||||
assert_eq!(opened, 0);
|
||||
assert_eq!(s.requested("textDocument/references").len(), 1);
|
||||
} else {
|
||||
assert_eq!(refs.incomplete, Some(ReferencesIncomplete::TooManyFiles));
|
||||
assert_eq!(opened, ide_lsp::MAX_CANDIDATES);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Files under the directories a server analyses fully are not read to find candidates.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn candidates_skip_the_directories_the_server_covers() {
|
||||
for (commands, reported) in [
|
||||
(json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(["src"])),
|
||||
(json!(["openidea.workspace.coveredFiles", "openidea.workspace.coveredRoots"]), json!(null)),
|
||||
(json!(["openidea.workspace.coveredFiles"]), json!(["src"])),
|
||||
] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"]["commands"] = commands.clone();
|
||||
let h = harness(&[("mock", caps)]);
|
||||
let s = h.launcher.state("mock");
|
||||
let src = h.root.join("src");
|
||||
let roots = reported.as_array().map(|r| r.iter().map(|_| json!(uri_of(&src))).collect::<Vec<_>>());
|
||||
let responder = open_files_only(vec![]);
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
if method == "workspace/executeCommand" {
|
||||
return Some(match params["command"].as_str() {
|
||||
Some("openidea.workspace.coveredRoots") => roots.clone().map_or(json!(null), Value::Array),
|
||||
_ => json!([]),
|
||||
});
|
||||
}
|
||||
responder(s, method, params)
|
||||
}));
|
||||
let seen = grep_candidates(&h);
|
||||
let (doc, _) = h.open("a.mk", "def target\n");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
h.mgr.references(doc, Pos::new(0, 5), true, true, Default::default()).await.unwrap();
|
||||
let skipped: Vec<Vec<PathBuf>> = seen.lock().iter().map(|q| q.skip.clone()).collect();
|
||||
let expected = if commands.as_array().unwrap().len() == 2 && reported.is_array() { vec![src] } else { vec![] };
|
||||
assert_eq!(skipped, vec![expected], "{commands} {reported}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn references_open_unopened_files_that_mention_the_name() {
|
||||
let (h, s) = scope_harness();
|
||||
@@ -3748,6 +3827,32 @@ async fn classified_java_references_widen_without_losing_metadata() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage counts probe many symbols at once: without `libraries` they search the sources alone.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn scoped_java_usages_search_the_sources_unless_libraries_are_asked_for() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
s.respond("workspace/executeCommand", json!([]));
|
||||
let (doc, _) = h.open("a.java", "int target;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
let seen = grep_candidates(&h);
|
||||
for libraries in [false, true] {
|
||||
let refs = h
|
||||
.mgr
|
||||
.references_streaming(doc, Pos::new(0, 5), false, false, libraries, Default::default(), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(refs.libraries_skipped, !libraries);
|
||||
let requested = s.requested("workspace/executeCommand");
|
||||
let arguments = requested.last().unwrap()["arguments"].as_array().unwrap().clone();
|
||||
assert_eq!(arguments.get(4) == Some(&json!("sources")), !libraries);
|
||||
assert_eq!(arguments.len(), if libraries { 4 } else { 5 });
|
||||
}
|
||||
assert!(seen.lock().is_empty());
|
||||
}
|
||||
|
||||
/// A library method's usages in the sources are shown before the dependencies and the JDK
|
||||
/// are searched, which takes seconds; scope widening searches the sources alone again.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -5117,3 +5222,264 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(e.message, "plain doesn't support Pull Members Up");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
|
||||
for encoding in ["utf-16", "utf-8"] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["positionEncoding"] = json!(encoding);
|
||||
caps["signatureHelpProvider"] = json!({});
|
||||
for cap in
|
||||
["declarationProvider", "typeDefinitionProvider", "implementationProvider", "documentHighlightProvider"]
|
||||
{
|
||||
caps[cap] = json!(true);
|
||||
}
|
||||
let mut h = harness_for("sql", "sql", &[("embedded", caps)], &[]);
|
||||
let builtins = LanguageRegistry::load(None).unwrap();
|
||||
let mut sql = builtins.get(&LanguageId::new("sql")).unwrap();
|
||||
sql.language_servers = vec![server_cfg("embedded")];
|
||||
let mut native = builtins.get(&LanguageId::plain_text()).unwrap();
|
||||
native.language_servers = vec![server_cfg("host")];
|
||||
*h.launcher.state("host").caps.lock() = incremental_caps();
|
||||
h.launcher.state("host").respond("textDocument/completion", json!([{"label":"native"}]));
|
||||
let events = h.events.clone();
|
||||
h.mgr = LspManager::with_launcher(
|
||||
h.root.clone(),
|
||||
Arc::new(LanguageRegistry::from_configs(vec![sql, native])),
|
||||
Arc::new(move |e| events.lock().push(e)),
|
||||
h.launcher.clone(),
|
||||
);
|
||||
let state = h.launcher.state("embedded");
|
||||
*state.responder.lock() = Some(Arc::new(|_, method, p| {
|
||||
let whole = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":99}});
|
||||
let uri = p["textDocument"]["uri"].clone();
|
||||
Some(match method {
|
||||
"textDocument/completion" => {
|
||||
json!([{"label":"one","textEdit":{"range":whole,"newText":"ONE"},"data":{"id":7},"command":{"title":"run","command":"mock.run"}}])
|
||||
}
|
||||
"completionItem/resolve" => {
|
||||
assert_eq!(p["data"], json!({"id":7}));
|
||||
assert_eq!(p["command"]["command"], "mock.run");
|
||||
json!({"label":"one","detail":"resolved"})
|
||||
}
|
||||
"textDocument/hover" => json!({"contents":"SQL","range":whole}),
|
||||
"textDocument/signatureHelp" => json!({"signatures":[{"label":"sql()"}]}),
|
||||
"textDocument/definition"
|
||||
| "textDocument/declaration"
|
||||
| "textDocument/typeDefinition"
|
||||
| "textDocument/implementation" => json!([
|
||||
{"uri":uri,"range":whole},
|
||||
{"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}
|
||||
]),
|
||||
"textDocument/references" => json!([{"uri":uri,"range":whole}]),
|
||||
"textDocument/documentHighlight" => json!([{"range":whole}]),
|
||||
"textDocument/codeAction" => json!([{"title":"fix","data":{"uri":uri}}]),
|
||||
"codeAction/resolve" => json!({"title":"fix","edit":{"documentChanges":[
|
||||
{"textDocument":{"uri":p["data"]["uri"],"version":1},"edits":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"ONE"}]},
|
||||
{"kind":"create","uri":p["data"]["uri"]}
|
||||
]},"command":{"title":"run","command":"mock.run"}}),
|
||||
_ => Value::Null,
|
||||
})
|
||||
}));
|
||||
h.mgr.set_injection_provider(Arc::new(|_, _, _, text| {
|
||||
text.match_indices("[[")
|
||||
.filter_map(|(start, _)| {
|
||||
let end = start + 2 + text[start + 2..].find("]]")?;
|
||||
Some(ide_lsp::InjectedRange {
|
||||
range: Range::new(
|
||||
Pos::new(0, text[..start + 2].encode_utf16().count() as u32),
|
||||
Pos::new(0, text[..end].encode_utf16().count() as u32),
|
||||
),
|
||||
language: LanguageId::new("sql"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}));
|
||||
let host = DocId::next();
|
||||
let path = h.root.join("host.java");
|
||||
let source = "😀[[one]] [[two]]";
|
||||
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, source);
|
||||
h.mgr.refresh_injections(host).await.unwrap();
|
||||
let doc = h.mgr.injected_document(host, Pos::new(0, 5));
|
||||
let second = h.mgr.injected_document(host, Pos::new(0, 13));
|
||||
assert_ne!(doc, host);
|
||||
assert_ne!(doc, second, "separate SQL fragments must not share a document");
|
||||
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 7)), host);
|
||||
assert_eq!(
|
||||
h.mgr.completion(host, Pos::new(0, 1), None, Default::default()).await.unwrap().items[0].label,
|
||||
"native"
|
||||
);
|
||||
let item = h.mgr.completion(doc, Pos::new(0, 5), None, Default::default()).await.unwrap().items.remove(0);
|
||||
assert_eq!(item.replace_range, Some(rng(0, 4, 0, 7)));
|
||||
let resolved = h.mgr.resolve_completion(host, item).await.unwrap();
|
||||
assert_eq!(resolved.detail.as_deref(), Some("resolved"));
|
||||
h.mgr.execute_command(host, resolved.command.clone().unwrap()).await.unwrap();
|
||||
assert_eq!(state.requested("workspace/executeCommand")[0]["command"], "mock.run");
|
||||
assert_eq!(
|
||||
h.mgr.hover(doc, Pos::new(0, 5), Default::default()).await.unwrap().unwrap().range,
|
||||
Some(rng(0, 4, 0, 7))
|
||||
);
|
||||
assert!(h.mgr.signature_help(doc, Pos::new(0, 5)).await.unwrap().is_some());
|
||||
for kind in [GotoKind::Definition, GotoKind::Declaration, GotoKind::TypeDefinition, GotoKind::Implementation] {
|
||||
let locations = h.mgr.goto(doc, Pos::new(0, 5), kind).await.unwrap();
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].path.0, path);
|
||||
assert_eq!(locations[0].range, rng(0, 4, 0, 7));
|
||||
}
|
||||
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(refs.locations[0].path.0, path);
|
||||
let highlights = h.mgr.document_highlights(doc, Pos::new(0, 5)).await.unwrap();
|
||||
assert_eq!(highlights[0].range, rng(0, 4, 0, 7));
|
||||
let action = h.mgr.code_actions(doc, rng(0, 5, 0, 5), vec![]).await.unwrap().remove(0);
|
||||
let action = h.mgr.resolve_code_action(host, action).await.unwrap();
|
||||
let edit = action.edit.unwrap();
|
||||
assert_eq!(edit.files.len(), 1, "resource operations on virtual paths are dropped");
|
||||
assert!(
|
||||
matches!(&edit.files[0],FileEdit::Edit {path:p,edits,..} if p.0 == path && edits[0].range == rng(0,4,0,7))
|
||||
);
|
||||
let command = action.command.unwrap();
|
||||
assert!(h.mgr.provides_command(host, Some(action.server), &command.command).await);
|
||||
assert!(
|
||||
h.mgr
|
||||
.resolve_command(host, action.server, command.clone(), vec![], Default::default())
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
h.mgr.execute_action_command(host, action.server, command, vec![], Default::default()).await.unwrap();
|
||||
eventually(|| state.docs.lock().len() == 2).await;
|
||||
assert!(state.docs.lock().values().any(|t| t == " one "));
|
||||
assert!(state.docs.lock().values().any(|t| t == " two "));
|
||||
let virtual_uris: Vec<_> = state.docs.lock().iter().map(|(u, t)| (u.clone(), t.clone())).collect();
|
||||
for (uri, text) in &virtual_uris {
|
||||
let col = if text.contains("one") { 4 } else { 12 };
|
||||
state.peer().notify("textDocument/publishDiagnostics", json!({"uri":uri,"version":1,"diagnostics":[
|
||||
{"range":{"start":{"line":0,"character":col},"end":{"line":0,"character":col+3}},"message":"embedded"},
|
||||
{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"message":"outside"}
|
||||
]})).unwrap();
|
||||
}
|
||||
let diagnostics = || {
|
||||
h.events
|
||||
.lock()
|
||||
.iter()
|
||||
.rev()
|
||||
.find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path: p, source, items } if p.0 == path && source == "embedded" => {
|
||||
Some(items.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
eventually(|| diagnostics().len() == 2).await;
|
||||
assert!(diagnostics().iter().all(|d| d.source.as_deref() == Some("embedded")));
|
||||
h.mgr.did_change(host, 2, &[AppliedChange { range: rng(0, 4, 0, 7), text: "ONE".into() }], &|| {
|
||||
"😀[[ONE]] [[two]]".into()
|
||||
});
|
||||
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), host, "stale ranges cannot route");
|
||||
h.mgr.refresh_injections(host).await.unwrap();
|
||||
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), doc);
|
||||
eventually(|| state.docs.lock().values().any(|t| t == " ONE ")).await;
|
||||
assert!(h.mgr.resolve_completion(host, resolved).await.is_err(), "old completions cannot resolve after edits");
|
||||
h.mgr.set_injections(host, 2, vec![]);
|
||||
eventually(|| diagnostics().is_empty()).await;
|
||||
assert!(h.events.lock().iter().all(
|
||||
|e| !matches!(e,IdeEvent::Diagnostics {path,..} if path.0.to_string_lossy().contains(".openidea-injected-"))
|
||||
));
|
||||
h.mgr.did_close(host);
|
||||
eventually(|| state.docs.lock().is_empty()).await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn missing_injected_server_uses_the_normal_single_install_notification() {
|
||||
let h = harness_for("sql", "sql", &[("embedded", incremental_caps())], &[]);
|
||||
h.launcher.state("embedded").missing.store(true, Ordering::SeqCst);
|
||||
let host = DocId::next();
|
||||
let path = h.root.join("host.java");
|
||||
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "sql");
|
||||
let ranges = || vec![ide_lsp::InjectedRange { range: rng(0, 0, 0, 3), language: LanguageId::new("sql") }];
|
||||
h.mgr.set_injections(host, 1, ranges());
|
||||
h.wait_status("embedded", ServerStatus::Missing).await;
|
||||
for version in 2..10 {
|
||||
h.mgr.did_change(host, version, &[], &|| "sql".into());
|
||||
h.mgr.set_injections(host, version, ranges());
|
||||
}
|
||||
assert_eq!(h.mgr.servers().len(), 1);
|
||||
assert_eq!(h.statuses("embedded").iter().filter(|s| **s == ServerStatus::Missing).count(), 1);
|
||||
h.mgr.did_close(host);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn languages_sharing_a_server_keep_independent_target_choices() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let language = |id: &str| {
|
||||
let mut lang: LanguageConfig = toml::from_str(&format!("id='{id}'\nname='{id}'\nextensions=['{id}']")).unwrap();
|
||||
lang.language_servers = vec![server_cfg("shared")];
|
||||
lang
|
||||
};
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(vec![language("one"), language("two")]));
|
||||
struct ScopedLauncher(Arc<MockLauncher>);
|
||||
impl Launcher for ScopedLauncher {
|
||||
fn launch(
|
||||
&self,
|
||||
config: &LanguageServerConfig,
|
||||
root: &Path,
|
||||
handler: Arc<dyn Handler>,
|
||||
stderr: Box<dyn Fn(String) + Send + Sync>,
|
||||
) -> Result<Peer, LaunchError> {
|
||||
let mut config = config.clone();
|
||||
if let Some(key) = &config.target_key {
|
||||
config.name = format!("{}@{key}", config.name);
|
||||
}
|
||||
self.0.launch(&config, root, handler, stderr)
|
||||
}
|
||||
}
|
||||
let launcher = Arc::new(MockLauncher::default());
|
||||
for name in ["shared", "shared@container"] {
|
||||
*launcher.state(name).caps.lock() = json!({"textDocumentSync":1,"hoverProvider":true});
|
||||
}
|
||||
let manager = LspManager::with_launcher(
|
||||
root.clone(),
|
||||
registry,
|
||||
Arc::new(ide_core::NullSink),
|
||||
Arc::new(ScopedLauncher(launcher.clone())),
|
||||
);
|
||||
let selected = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let selected_hook = selected.clone();
|
||||
manager.set_servers_hook(Box::new(move |lang, defined| {
|
||||
let mut configs = defined.to_vec();
|
||||
if lang.as_str() == "one" && selected_hook.load(Ordering::Acquire) {
|
||||
configs[0].target_key = Some("container".into());
|
||||
}
|
||||
configs
|
||||
}));
|
||||
let one = DocId::next();
|
||||
let two = DocId::next();
|
||||
manager.did_open(one, &root.join("a.one"), &LanguageId::new("one"), 1, "one");
|
||||
manager.did_open(two, &root.join("b.two"), &LanguageId::new("two"), 1, "two");
|
||||
eventually(|| manager.servers().iter().any(|s| s.status == ServerStatus::Ready)).await;
|
||||
assert_eq!(manager.servers().len(), 1);
|
||||
let host = manager.servers()[0].id;
|
||||
selected.store(true, Ordering::Release);
|
||||
manager.reconfigure().await;
|
||||
eventually(|| manager.servers().iter().filter(|s| s.status == ServerStatus::Ready).count() == 2).await;
|
||||
assert!(manager.servers().iter().any(|s| s.id == host));
|
||||
// Each document has one server. Hover must not send to both after splitting.
|
||||
let state = launcher.state("shared");
|
||||
state.requests.lock().clear();
|
||||
let target_state = launcher.state("shared@container");
|
||||
target_state.requests.lock().clear();
|
||||
let _ = manager.hover(one, Pos::new(0, 0), CancellationToken::new()).await;
|
||||
let _ = manager.hover(two, Pos::new(0, 0), CancellationToken::new()).await;
|
||||
assert_eq!(state.requested("textDocument/hover").len(), 1);
|
||||
assert_eq!(target_state.requested("textDocument/hover").len(), 1);
|
||||
selected.store(false, Ordering::Release);
|
||||
manager.reconfigure().await;
|
||||
assert_eq!(manager.servers().len(), 1);
|
||||
assert_eq!(manager.servers()[0].id, host);
|
||||
manager.shutdown_all().await;
|
||||
}
|
||||
@@ -327,6 +327,8 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
let path = dir.path().join("a.nope");
|
||||
@@ -598,6 +600,8 @@ async fn typescript_language_server_end_to_end() {
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
let doc = DocId::next();
|
||||
@@ -649,6 +653,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -102,3 +102,54 @@ async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
|
||||
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn target_launch_failure_shows_exact_argv_and_stderr_tail() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let wrapper = root.join("target wrapper");
|
||||
std::fs::write(&wrapper, "#!/bin/sh\ni=0\nwhile [ $i -lt 60 ]; do echo stderr-$i >&2; i=$((i+1)); done\nexit 37\n")
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&wrapper, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
|
||||
let mut server: LanguageServerConfig = toml::from_str("name='target-ls'\ncommand='unused'").unwrap();
|
||||
server.command = wrapper.to_string_lossy().into_owned();
|
||||
server.args = vec!["literal prefix".into(), "--cwd=/container".into(), "missing-lsp".into()];
|
||||
server.pinned = Some(ide_language::ProgramPin::approve(&wrapper).unwrap());
|
||||
server.target = Some(ide_language::ServerTarget {
|
||||
id: "test-target".into(),
|
||||
origin: None,
|
||||
project_root: root.clone(),
|
||||
container_root: "/container".into(),
|
||||
mapping: ide_core::path_mapping::PathMapping::new(&root.to_string_lossy(), "/container"),
|
||||
});
|
||||
let expected =
|
||||
format!("argv: {:?}", std::iter::once(&server.command).chain(server.args.iter()).collect::<Vec<_>>());
|
||||
let mut language: LanguageConfig = toml::from_str("id='mock'\nname='Mock'\nextensions=['mock']").unwrap();
|
||||
language.language_servers = vec![server];
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink_events = events.clone();
|
||||
let manager = LspManager::new(
|
||||
root.clone(),
|
||||
Arc::new(LanguageRegistry::from_configs(vec![language])),
|
||||
Arc::new(move |event: IdeEvent| sink_events.lock().push(event)),
|
||||
);
|
||||
manager.did_open(DocId::next(), &root.join("main.mock"), &LanguageId::new("mock"), 1, "x");
|
||||
let message = tokio::time::timeout(Duration::from_secs(10), async {
|
||||
loop {
|
||||
if let Some(message) = events.lock().iter().find_map(|event| match event {
|
||||
IdeEvent::LanguageServer { status: ServerStatus::Crashed, message, .. } => message.clone(),
|
||||
_ => None,
|
||||
}) {
|
||||
break message;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(message.contains("exited with code 37"), "{message}");
|
||||
assert!(message.contains("stderr-59"), "{message}");
|
||||
assert!(message.contains(&expected), "{message}");
|
||||
assert!(!message.contains("stderr-0\n"), "{message}");
|
||||
manager.shutdown_all().await;
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
|
||||
4647fa9aec9732e3dd824efccd61b1c6b9c36645a3586f402f6a7a47419db61f
|
||||
@@ -1 +1 @@
|
||||
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
|
||||
6787b195df1057176ea9fcf11d0fcaf8cd347fc182fab94fe3a229063deed831
|
||||
@@ -1 +1 @@
|
||||
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
|
||||
c2d17076337b1225ef8b0323b335a3b03bf022485088957c520e0cb797d7df7d
|
||||
@@ -1 +1 @@
|
||||
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
|
||||
415a4cd17c573aeeb1b45d4b1af55834eab5e686c4f624495323e4fb63c3ac71
|
||||
@@ -1 +1 @@
|
||||
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
|
||||
5f502b7650acd5d07bffba8016680248b5ceac0926013916ff351562e3c6d831
|
||||
@@ -1 +1 @@
|
||||
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
|
||||
19e8d460269999aeac7b4021bfedb803ad0d68c24ce1c22f058070fb406a64c3
|
||||
@@ -1 +1 @@
|
||||
4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f
|
||||
63809abb0f8a4f40b0c0f465062cc8ba0ebfd03d41a108ff19866e6565eda931
|
||||
@@ -1 +1 @@
|
||||
110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898
|
||||
99c968b98d8f1d895c428b4048451275bd322e7070aa0a020737097efea17eb1
|
||||
@@ -1 +1 @@
|
||||
2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436
|
||||
c40a0fd8c6cf791e70ba0335c6cf0b2c82b250670ebad59b185e3e8719172409
|
||||
@@ -1 +1 @@
|
||||
6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97
|
||||
162774155d8b49d53b0c0c055736bd098723c66b02c0e1dc4389985982c57282
|
||||
@@ -1 +1 @@
|
||||
a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863
|
||||
39e3de11f69c09348906878367318157fca9a777f9ac1c4fb6ed303fa058e5a2
|
||||
@@ -1 +1 @@
|
||||
f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0
|
||||
4a3f595d2a6a9b79da7e316a3da86c287e9761ea670319c331467e7b10639663
|
||||
@@ -1 +1 @@
|
||||
50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41
|
||||
bede1f0eaec356e3e35302e65e85531870625560bc9a3197f1acb58969beb4bd
|
||||
@@ -1 +1 @@
|
||||
b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161
|
||||
ad2dc0ad12adb9c6b25017b2cef78b7496e96d1bb6e88b24ea9b7b682120e786
|
||||
@@ -1 +1 @@
|
||||
b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321
|
||||
2c329cf7af36c9d65ed5b6be7c2a017c7a5a6214a4893cab7ae574acb76fe62f
|
||||
+1
-1
@@ -1 +1 @@
|
||||
91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10
|
||||
39c85942177e937e48f32bcde86a0068dac484ba028a8d6785a5a2728db9e11b
|
||||
@@ -1 +1 @@
|
||||
3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c
|
||||
30a8cc05a40c7b99a78700430ae87d025ad7567b44384fdb696119aa421f8174
|
||||
@@ -1 +1 @@
|
||||
83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd
|
||||
5f9c92b23c00269ec2b5dc8829ce526a8fc4c0d76ecb2925ec9a06ffeac5f0b4
|
||||
@@ -1 +1 @@
|
||||
557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08
|
||||
d695409029a66c8a44f524072be1d47ed974fc8810f6fe1d8d068765184d4028
|
||||
@@ -1 +1 @@
|
||||
884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5
|
||||
d5e81a43dc2c5298d0aaafd70c0bd91ab6cc040d70224d2936349de41253735f
|
||||
@@ -1 +1 @@
|
||||
5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59
|
||||
c29076023ccedf2da8fd9e5a851a47bbe7131b97713c8140a20cd55608a1cc0d
|
||||
@@ -1 +1 @@
|
||||
253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0
|
||||
616157bd77dc272f9e2dfed4c54c9d65fca110e55ad01f92b7c4f8a6136537b4
|
||||
@@ -1 +1 @@
|
||||
93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d
|
||||
022f216f0f4ca5da56f4b2fabf5ee4a987d5e9f9a752feecbb438f9b00b1acf4
|
||||
@@ -1 +1 @@
|
||||
62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391
|
||||
626f0e0c83e69704c0365c273b6f63d43278ee37786f38ed467ce908c30eadf7
|
||||
@@ -1 +1 @@
|
||||
4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e
|
||||
aa141701049f5c6c0d045341c3337344ed7a368800f08237b1b7812cce216814
|
||||
@@ -1 +1 @@
|
||||
3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b
|
||||
d6c40099cc959ef2dead996beeb4acd73c1327597cd029674026c6f2bc878300
|
||||
@@ -1 +1 @@
|
||||
70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5
|
||||
3201f643cf868f7a358ba812f9c90e06b982d864ed141b6b4c3fdeee13cb1559
|
||||
@@ -271,6 +271,11 @@ impl Cx<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// A filesystem path: expand IDEA's macros before converting a file URL.
|
||||
fn path(&self, value: Option<&str>) -> Res<Option<String>> {
|
||||
Ok(self.text(value)?.map(|v| file_url_path(&v)))
|
||||
}
|
||||
|
||||
/// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`.
|
||||
fn words(&self, value: Option<&str>) -> Res<Vec<String>> {
|
||||
self.words_quoted(value, Quotes::Double)
|
||||
@@ -286,12 +291,16 @@ impl Cx<'_> {
|
||||
}
|
||||
|
||||
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
|
||||
Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect())
|
||||
Ok(self
|
||||
.words_quoted(value, quotes)?
|
||||
.into_iter()
|
||||
.map(|a| path_from(self.root, file_url_path(&a), dir))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The working directory (absolute) and the configuration's `cwd`.
|
||||
fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option<PathBuf>)> {
|
||||
Ok(match self.text(value)? {
|
||||
Ok(match self.path(value)? {
|
||||
Some(dir) => working_dir(self.root, &dir),
|
||||
None => (self.root.to_path_buf(), None),
|
||||
})
|
||||
@@ -350,7 +359,7 @@ impl Cx<'_> {
|
||||
/// options, and the working directory.
|
||||
fn interpreter(&self, c: &El, ignored: &mut Vec<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
|
||||
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
|
||||
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? };
|
||||
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.path(c.option("SDK_HOME"))? };
|
||||
let (exe, mut args) = match sdk {
|
||||
Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()),
|
||||
None => project_python(self.root, &dir),
|
||||
@@ -369,7 +378,7 @@ impl Cx<'_> {
|
||||
rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false");
|
||||
// PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`.
|
||||
for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) {
|
||||
if let Some(file) = self.text(Some(file))? {
|
||||
if let Some(file) = self.path(Some(file))? {
|
||||
rc.extras.env_files.push(path_from(self.root, file, self.root));
|
||||
}
|
||||
}
|
||||
@@ -403,7 +412,7 @@ impl Cx<'_> {
|
||||
ignored.push(format!("EnvFile {path}"));
|
||||
continue;
|
||||
}
|
||||
if let Some(file) = self.text(Some(path))? {
|
||||
if let Some(file) = self.path(Some(path))? {
|
||||
rc.extras.env_files.push(path_from(self.root, file, self.root));
|
||||
}
|
||||
}
|
||||
@@ -418,7 +427,7 @@ impl Cx<'_> {
|
||||
if c.flag("MODULE_MODE") {
|
||||
args.extend(["-m".to_owned(), script]);
|
||||
} else {
|
||||
args.push(path_from(self.root, script, &dir));
|
||||
args.push(path_from(self.root, file_url_path(&script), &dir));
|
||||
}
|
||||
args.extend(self.args(c.option("PARAMETERS"), &dir)?);
|
||||
let mut rc = config(name, "python", exe, args, Some("debugpy"));
|
||||
@@ -480,7 +489,7 @@ impl Cx<'_> {
|
||||
args.extend([manage, "test".to_owned()]);
|
||||
args.extend(self.words(c.option("TARGET"))?);
|
||||
if c.flag("CUSTOM_SETTINGS")
|
||||
&& let Some(settings) = self.text(c.option("SETTINGS_FILE"))?
|
||||
&& let Some(settings) = self.path(c.option("SETTINGS_FILE"))?
|
||||
{
|
||||
args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]);
|
||||
}
|
||||
@@ -519,7 +528,7 @@ impl Cx<'_> {
|
||||
}
|
||||
}
|
||||
Some("CUSTOM") => {}
|
||||
_ => args.push(path_from(self.root, target, &dir)),
|
||||
_ => args.push(path_from(self.root, file_url_path(&target), &dir)),
|
||||
}
|
||||
}
|
||||
if target_type.as_deref() != Some("CUSTOM")
|
||||
@@ -574,7 +583,7 @@ impl Cx<'_> {
|
||||
fn node_interpreter(&self, c: &El) -> Res<Option<String>> {
|
||||
match c.value("node-interpreter").or_else(|| c.attr("node-interpreter")).map(str::trim) {
|
||||
None | Some("" | "project") => Ok(None),
|
||||
interpreter => self.text(interpreter),
|
||||
interpreter => self.path(interpreter),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -587,12 +596,12 @@ impl Cx<'_> {
|
||||
}
|
||||
|
||||
fn npm(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let package_json = self.text(c.value("package-json"))?;
|
||||
let package_json = self.path(c.value("package-json"))?;
|
||||
let dir = match &package_json {
|
||||
Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()),
|
||||
None => self.root.to_path_buf(),
|
||||
};
|
||||
let manager = match self.text(c.value("package-manager"))? {
|
||||
let manager = match self.path(c.value("package-manager"))? {
|
||||
Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(),
|
||||
_ => {
|
||||
let pkg = std::fs::read_to_string(dir.join("package.json"))
|
||||
@@ -650,14 +659,14 @@ impl Cx<'_> {
|
||||
fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
|
||||
let mut args: Vec<String> = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] };
|
||||
if !vitest && let Some(config_file) = self.text(c.value("config-file"))? {
|
||||
if !vitest && let Some(config_file) = self.path(c.value("config-file"))? {
|
||||
args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]);
|
||||
}
|
||||
args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?);
|
||||
match c.value("scope-kind").unwrap_or("ALL") {
|
||||
"DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
|
||||
"DIRECTORY" => args.extend(self.path(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
|
||||
"TEST_FILE" | "SUITE" | "TEST" => {
|
||||
args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
|
||||
args.extend(self.path(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
|
||||
let names: Vec<&str> = c
|
||||
.child("test-names")
|
||||
.into_iter()
|
||||
@@ -686,7 +695,7 @@ impl Cx<'_> {
|
||||
|
||||
fn node(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.attr("working-dir"))?;
|
||||
let Some(file) = self.text(c.attr("path-to-js-file"))? else {
|
||||
let Some(file) = self.path(c.attr("path-to-js-file"))? else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
};
|
||||
let mut args = self.words(c.attr("node-parameters"))?;
|
||||
@@ -756,11 +765,11 @@ impl Cx<'_> {
|
||||
let mut params = BTreeMap::new();
|
||||
// Both default to true when missing (`ShRunConfiguration.java:148-149`).
|
||||
let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") {
|
||||
let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
|
||||
let Some(path) = self.path(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
|
||||
let path = path_from(self.root, path, &dir);
|
||||
let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default();
|
||||
let interpreter =
|
||||
self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
|
||||
self.path(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
|
||||
let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default();
|
||||
// Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH.
|
||||
let path = if interpreter.is_empty() { dot_slash(path) } else { path };
|
||||
@@ -861,7 +870,67 @@ const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
|
||||
/// The `ENV_FILES` paths, joined with `separator` (intellij-community
|
||||
/// `AbstractPythonRunConfiguration.java:303-304`, `:337`).
|
||||
fn split_env_files(value: &str, separator: char) -> impl Iterator<Item = &str> {
|
||||
value.split(separator).filter(|f| !f.trim().is_empty())
|
||||
let mut rest = value;
|
||||
std::iter::from_fn(move || {
|
||||
if rest.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let end = rest.match_indices(separator).find_map(|(end, _)| {
|
||||
let field = rest[..end].trim_start();
|
||||
if separator == ':' {
|
||||
// A URL's scheme and optional drive letter aren't list separators.
|
||||
let scheme = field.eq_ignore_ascii_case("file");
|
||||
let drive = field.strip_prefix("file:").is_some_and(|body| {
|
||||
let body = body.trim_start_matches('/');
|
||||
let body = body
|
||||
.split_once('/')
|
||||
.filter(|(host, _)| host.eq_ignore_ascii_case("localhost"))
|
||||
.map_or(body, |(_, path)| path);
|
||||
body.len() == 1 && body.as_bytes()[0].is_ascii_alphabetic()
|
||||
});
|
||||
if scheme || drive {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
Some(end)
|
||||
});
|
||||
let field = match end {
|
||||
Some(end) => {
|
||||
let field = &rest[..end];
|
||||
rest = &rest[end + separator.len_utf8()..];
|
||||
field
|
||||
}
|
||||
None => std::mem::take(&mut rest),
|
||||
};
|
||||
Some(field)
|
||||
})
|
||||
.filter(|f| !f.trim().is_empty())
|
||||
}
|
||||
|
||||
/// The path a `file:` URL names, and `value` unchanged when it is not one.
|
||||
///
|
||||
/// IDEA writes path options as URLs (`file://$PROJECT_DIR$`), which expand to
|
||||
/// `file://C:/project` on Windows: the drive letter lands where a URL's authority
|
||||
/// belongs. Parsing that as a path yields `C:project`, a drive-relative path that
|
||||
/// then resolves against the project directory instead of replacing it.
|
||||
fn file_url_path(value: &str) -> String {
|
||||
let Some(rest) = value.strip_prefix("file:") else {
|
||||
return value.to_owned();
|
||||
};
|
||||
let rest = match rest.strip_prefix("//").and_then(|r| r.split_once('/')) {
|
||||
Some((host, path)) if host.eq_ignore_ascii_case("localhost") => path,
|
||||
_ => rest,
|
||||
};
|
||||
let body = rest.trim_start_matches('/');
|
||||
let drive = matches!(body.as_bytes(), [d, b':', ..] if d.is_ascii_alphabetic());
|
||||
if drive {
|
||||
body.to_owned()
|
||||
} else if rest.starts_with("//") && !rest.starts_with("///") {
|
||||
// A real authority (`file://server/share`): a UNC path.
|
||||
format!("//{body}")
|
||||
} else {
|
||||
format!("/{body}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Keywords without the quotes around them (`"not slow"` → `not slow`).
|
||||
@@ -965,6 +1034,57 @@ mod tests {
|
||||
assert!(parse("<a><b></a>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_become_paths_whatever_the_drive_letter_looks_like() {
|
||||
// IDEA on Windows: the drive letter sits where the authority belongs.
|
||||
assert_eq!(file_url_path("file://C:/p"), "C:/p");
|
||||
assert_eq!(file_url_path(r"file://C:\p"), r"C:\p");
|
||||
assert_eq!(file_url_path("file:///C:/p"), "C:/p");
|
||||
assert_eq!(file_url_path("file:///p/a"), "/p/a");
|
||||
assert_eq!(file_url_path("file://server/share"), "//server/share");
|
||||
// Not a URL: left alone, including a bare Windows path.
|
||||
assert_eq!(file_url_path(r"C:\p"), r"C:\p");
|
||||
assert_eq!(file_url_path("/p/a"), "/p/a");
|
||||
assert_eq!(file_url_path(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_preserve_paths_and_treat_localhost_as_local() {
|
||||
for (url, expected) in [
|
||||
("file:///p/100%20", "/p/100%20"),
|
||||
("file:///p/a%E4", "/p/a%E4"),
|
||||
("file:///p/a b/中.py", "/p/a b/中.py"),
|
||||
("file://C:/a b/é", "C:/a b/é"),
|
||||
("file:///C:/a%20b", "C:/a%20b"),
|
||||
("file://localhost/p/a%20b", "/p/a%20b"),
|
||||
("file://localhost/C:/a%20b", "C:/a%20b"),
|
||||
("file://LOCALHOST/p", "/p"),
|
||||
("file://server/share/a%20b", "//server/share/a%20b"),
|
||||
("file:///p/%2520", "/p/%2520"),
|
||||
("/p/a%20b", "/p/a%20b"),
|
||||
("relative%20path", "relative%20path"),
|
||||
("https://localhost/a%20b", "https://localhost/a%20b"),
|
||||
] {
|
||||
assert_eq!(file_url_path(url), expected, "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The property the bug broke: `working_dir` joins its `cwd` onto the project
|
||||
/// directory, so a URL that reads as a relative path resolves inside it instead
|
||||
/// of replacing it.
|
||||
#[test]
|
||||
fn file_urls_name_absolute_paths() {
|
||||
let absolute = if cfg!(windows) {
|
||||
vec!["file://C:/p", "file:///C:/p", r"file://C:\p", "file://server/share"]
|
||||
} else {
|
||||
vec!["file:///p/a", "file://server/share"]
|
||||
};
|
||||
for url in absolute {
|
||||
let path = file_url_path(url);
|
||||
assert!(Path::new(&path).is_absolute(), "{url} became {path:?}, which is not absolute");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_files_split_on_the_path_separator() {
|
||||
assert_eq!(
|
||||
@@ -975,6 +1095,19 @@ mod tests {
|
||||
split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::<Vec<_>>(),
|
||||
[r"C:\p\.env", r"C:\p\.env.local"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_env_files("file://$PROJECT_DIR$/.env:file://localhost/p/a%20b:.env.local", ':').collect::<Vec<_>>(),
|
||||
["file://$PROJECT_DIR$/.env", "file://localhost/p/a%20b", ".env.local"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_env_files("file://C:/p/.env:file:///D:/p/.env:file://localhost/E:/p/.env%3Adev", ':')
|
||||
.collect::<Vec<_>>(),
|
||||
["file://C:/p/.env", "file:///D:/p/.env", "file://localhost/E:/p/.env%3Adev"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_env_files("file://C:/p/.env;file://localhost/C:/p/.env.local", ';').collect::<Vec<_>>(),
|
||||
["file://C:/p/.env", "file://localhost/C:/p/.env.local"]
|
||||
);
|
||||
assert_eq!(split_env_files("", ':').count(), 0);
|
||||
assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' });
|
||||
}
|
||||
|
||||
+156
-11
@@ -849,21 +849,115 @@ fn sync_tree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A built `*.war` under a module's usual output directory (`target/`, `build/libs/`).
|
||||
fn is_war(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))
|
||||
}
|
||||
|
||||
/// The module directory a config's `cwd` names (absolute, or relative to the project root).
|
||||
fn module_dir(root: &Path, module: Option<&Path>) -> PathBuf {
|
||||
match module {
|
||||
Some(m) if m.is_absolute() => m.to_path_buf(),
|
||||
Some(m) => root.join(m),
|
||||
None => root.to_path_buf(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The newest modification time of `path` or, for a directory, anything below it.
|
||||
fn newest_mtime_under(path: &Path) -> Option<std::time::SystemTime> {
|
||||
let meta = fs::metadata(path).ok()?;
|
||||
let mut newest = meta.modified().ok();
|
||||
if meta.is_dir() {
|
||||
for entry in fs::read_dir(path).ok()?.flatten() {
|
||||
if let Some(time) = newest_mtime_under(&entry.path())
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
newest
|
||||
}
|
||||
|
||||
/// The newest `*.war` a build tool left in the module's output directories. Used when no
|
||||
/// build ran this launch and nothing was recorded, so a project that was already built can
|
||||
/// start directly (IDEA deploys the existing artifact instead of rebuilding it).
|
||||
fn find_built_war(root: &Path, module: Option<&Path>) -> Option<PathBuf> {
|
||||
let module = module_dir(root, module);
|
||||
let mut newest: Option<(std::time::SystemTime, PathBuf)> = None;
|
||||
for dir in [module.join("target"), module.join("build/libs")] {
|
||||
let Ok(entries) = fs::read_dir(dir) else { continue };
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && is_war(&path) {
|
||||
let modified = entry.metadata().and_then(|m| m.modified()).unwrap_or(std::time::UNIX_EPOCH);
|
||||
if newest.as_ref().is_none_or(|(at, _)| modified > *at) {
|
||||
newest = Some((modified, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
newest.map(|(_, path)| path)
|
||||
}
|
||||
|
||||
/// Whether the WAR a Tomcat config deploys is newer than the module's sources, so its
|
||||
/// "Before launch › Build" task can be skipped (IDEA's up-to-date check). Conservative:
|
||||
/// a missing WAR or a newer source/build file means it is not current. Only the module's
|
||||
/// own sources and build files are inspected, not dependency modules.
|
||||
pub fn artifact_is_current(root: &Path, base: &Path, config: &RunConfig) -> bool {
|
||||
let p = &config.extras.params;
|
||||
if p.get("deployment").map(String::as_str).unwrap_or("artifact") != "artifact" {
|
||||
return true;
|
||||
}
|
||||
let module = module_dir(root, config.cwd.as_ref().map(|c| c.0.as_path()));
|
||||
let selected = p.get("artifact").filter(|s| !s.is_empty());
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim()));
|
||||
let war = match recorded.filter(|_| id_matches) {
|
||||
Some(war) if war.is_file() && is_war(&war) => war,
|
||||
_ => match find_built_war(root, Some(&module)) {
|
||||
Some(war) => war,
|
||||
None => return false,
|
||||
},
|
||||
};
|
||||
let Ok(war_time) = fs::metadata(&war).and_then(|m| m.modified()) else { return false };
|
||||
let mut newest = newest_mtime_under(&module.join("src"));
|
||||
for dir in [module.as_path(), root] {
|
||||
for name in ["pom.xml", "build.gradle", "build.gradle.kts"] {
|
||||
if let Ok(time) = fs::metadata(dir.join(name)).and_then(|m| m.modified())
|
||||
&& newest.is_none_or(|cur| time > cur)
|
||||
{
|
||||
newest = Some(time);
|
||||
}
|
||||
}
|
||||
}
|
||||
newest.is_none_or(|time| time <= war_time)
|
||||
}
|
||||
|
||||
pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult<PathBuf> {
|
||||
let p = &config.extras.params;
|
||||
match p.get("deployment").map(String::as_str).unwrap_or("artifact") {
|
||||
"artifact" => {
|
||||
if let Some(id) = p.get("artifact").filter(|s| !s.is_empty())
|
||||
&& fs::read_to_string(base.join("artifact-id.txt")).ok().as_ref() != Some(id)
|
||||
{
|
||||
return Err(invalid("Build the selected WAR artifact before launching Tomcat"));
|
||||
}
|
||||
let war = fs::read_to_string(base.join("artifact-path.txt"))
|
||||
.map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?;
|
||||
let war = PathBuf::from(war.trim());
|
||||
if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) {
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
// A build run this launch records its archive here. Without one (Before launch ›
|
||||
// Build removed, as IDEA allows), reuse the last build's archive, or an archive a
|
||||
// build tool already left in the module output, instead of rebuilding.
|
||||
let selected = p.get("artifact").filter(|s| !s.is_empty());
|
||||
let recorded_id = fs::read_to_string(base.join("artifact-id.txt")).ok();
|
||||
let id_matches = selected.is_none_or(|id| recorded_id.as_deref().map(str::trim) == Some(id.as_str()));
|
||||
let recorded = fs::read_to_string(base.join("artifact-path.txt")).ok().map(|s| PathBuf::from(s.trim()));
|
||||
let war = match recorded.filter(|_| id_matches) {
|
||||
Some(war) => {
|
||||
if war.is_file() && is_war(&war) {
|
||||
war
|
||||
} else {
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
}
|
||||
None => find_built_war(root, config.cwd.as_ref().map(|c| c.0.as_path())).ok_or_else(|| {
|
||||
invalid("No built WAR artifact found - build the project first, or add 'Build' to Before launch")
|
||||
})?,
|
||||
};
|
||||
let dest = base.join("deployment");
|
||||
explode(&war, &dest, resources_only)?;
|
||||
Ok(dest)
|
||||
@@ -1402,6 +1496,57 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn reuses_a_built_war_when_no_build_ran() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("index.html", "built")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
// No artifact-path.txt / artifact-id.txt: it was never built through Tomcat.
|
||||
let dest = deployment(root, &base, &c, false).unwrap();
|
||||
assert_eq!(fs::read_to_string(dest.join("index.html")).unwrap(), "built");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_a_missing_artifact_when_nothing_was_built() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string();
|
||||
assert!(error.contains("No built WAR artifact found"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_the_build_when_the_built_war_is_current() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let module = root.join("web");
|
||||
fs::create_dir_all(module.join("src/main/java")).unwrap();
|
||||
fs::create_dir_all(module.join("target")).unwrap();
|
||||
fs::write(module.join("pom.xml"), "<project/>").unwrap();
|
||||
let source = module.join("src/main/java/App.java");
|
||||
fs::write(&source, "class App {}").unwrap();
|
||||
let war = module.join("target/app.war");
|
||||
archive(&war, &[("index.html", "built")]);
|
||||
let base = root.join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let mut c = config();
|
||||
c.cwd = Some(ide_core::IpcPath(PathBuf::from("web")));
|
||||
c.extras.params.insert("artifact".into(), "maven:web/pom.xml".into());
|
||||
// The WAR was packed after the source was written.
|
||||
assert!(artifact_is_current(root, &base, &c));
|
||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
||||
fs::write(&source, "class App { int changed; }").unwrap();
|
||||
assert!(!artifact_is_current(root, &base, &c));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepares_isolated_base_and_jdwp_only_for_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -787,3 +787,223 @@ fn java_applications_and_spring_boot_keep_their_module() {
|
||||
|
||||
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
|
||||
}
|
||||
|
||||
/// IDEA writes the working directory as a URL. On Windows `file://$PROJECT_DIR$`
|
||||
/// expands to `file://C:/project`, where the drive letter sits where the authority
|
||||
/// belongs; read as a path that is `C:project`, which resolves against the project
|
||||
/// directory instead of replacing it and the launch fails on the doubled path.
|
||||
#[test]
|
||||
fn file_url_working_directories_stay_inside_the_project() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
|
||||
write(
|
||||
&root,
|
||||
"gateway/pom.xml",
|
||||
"<project><parent><artifactId>shop</artifactId></parent><artifactId>gateway</artifactId></project>",
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
".run/Gateway.run.xml",
|
||||
&run_file(
|
||||
"Gateway",
|
||||
r#"<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="gateway" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.GatewayApplication" />
|
||||
<option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$" />
|
||||
<option name="ACTIVE_PROFILES" value="dev,local" />
|
||||
<option name="VM_PARAMETERS" value="-Xmx512m" />
|
||||
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
|
||||
<envs><env name="TZ" value="UTC" /></envs>
|
||||
<method v="2"><option name="Make" enabled="true" /></method>
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
".run/Sub.run.xml",
|
||||
&run_file(
|
||||
"Sub",
|
||||
r#"<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="gateway" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.SubApplication" />
|
||||
<option name="WORKING_DIRECTORY" value="file://$PROJECT_DIR$/gateway" />
|
||||
<method v="2" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
|
||||
let local_root = root.to_string_lossy().replace('\\', "/");
|
||||
for (name, working_directory) in [
|
||||
("Literal", "file://$PROJECT_DIR$/gateway é".to_owned()),
|
||||
("Localhost", format!("file://localhost/{}", local_root.trim_start_matches('/'))),
|
||||
] {
|
||||
write(
|
||||
&root,
|
||||
&format!(".run/{name}.run.xml"),
|
||||
&run_file(
|
||||
name,
|
||||
&format!(
|
||||
r#"
|
||||
<configuration name="NAME" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
|
||||
<module name="gateway" />
|
||||
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.GatewayApplication" />
|
||||
<option name="WORKING_DIRECTORY" value="{working_directory}" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
let r = import_from(&root, &[]);
|
||||
let boot = get(&r, "Gateway");
|
||||
assert_eq!(boot.kind, "spring-boot");
|
||||
assert_eq!(boot.extras.module.as_deref(), Some("gateway"));
|
||||
assert_eq!(
|
||||
boot.args,
|
||||
s(&[
|
||||
"-Xmx512m",
|
||||
"-cp",
|
||||
"${classpath}",
|
||||
"com.example.GatewayApplication",
|
||||
"--spring.profiles.active=dev,local",
|
||||
"--server.port=8081"
|
||||
])
|
||||
);
|
||||
assert_eq!(boot.env["TZ"], "UTC");
|
||||
assert_eq!(cwd(get(&r, "Literal")), Some(PathBuf::from("gateway é")));
|
||||
assert_eq!(cwd(get(&r, "Localhost")), None);
|
||||
|
||||
// The project directory itself: no `cwd` to store.
|
||||
assert_eq!(cwd(get(&r, "Gateway")), None);
|
||||
assert_eq!(cwd(get(&r, "Sub")), Some(PathBuf::from("gateway")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_work_in_all_imported_path_fields() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let cases = [
|
||||
(
|
||||
"Python",
|
||||
r#"<configuration name="NAME" type="PythonConfigurationType">
|
||||
<option name="WORKING_DIRECTORY" value="{base}/jobs 中" />
|
||||
<option name="SDK_HOME" value="{base}/bin/python" />
|
||||
<option name="SCRIPT_NAME" value="{base}/jobs 中/run me.py" />
|
||||
<option name="PARAMETERS" value=""{base}/data file" unchanged%20arg" />
|
||||
<option name="ENV_FILES" value="{base}/.env dev{sep}{base}/.env local" />
|
||||
<extension name="net.ashald.envfile">
|
||||
<option name="IS_ENABLED" value="true" />
|
||||
<ENTRIES><ENTRY PATH="{base}/.env plugin" PARSER="env" /></ENTRIES>
|
||||
</extension>
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Django",
|
||||
r#"<configuration name="NAME" type="DjangoTestsConfigurationType">
|
||||
<option name="CUSTOM_SETTINGS" value="true" />
|
||||
<option name="SETTINGS_FILE" value="{base}/settings/dev.py" />
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Pytest",
|
||||
r#"<configuration name="NAME" type="tests" factoryName="pytest">
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<option name="_new_target" value=""{base}/test file.py"" />
|
||||
<option name="_new_additionalArguments" value=""'{base}/data file'"" />
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Npm",
|
||||
r#"<configuration name="NAME" type="js.build_tools.npm">
|
||||
<package-json value="{base}/web app/package.json" />
|
||||
<package-manager value="{base}/bin/pnpm" />
|
||||
<node-interpreter value="{base}/bin/node" />
|
||||
<command value="run" /><scripts><script value="start" /></scripts>
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Jest",
|
||||
r#"<configuration name="NAME" type="JavaScriptTestRunnerJest">
|
||||
<working-dir value="{base}/web app" />
|
||||
<config-file value="{base}/web app/jest config.js" />
|
||||
<scope-kind value="TEST_FILE" /><test-file value="{base}/web app/test file.js" />
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Vitest",
|
||||
r#"<configuration name="NAME" type="JavaScriptTestRunnerVitest">
|
||||
<scope-kind value="DIRECTORY" /><test-directory value="{base}/test dir" />
|
||||
</configuration>"#,
|
||||
),
|
||||
(
|
||||
"Node",
|
||||
r#"<configuration name="NAME" type="NodeJSConfigurationType"
|
||||
working-dir="{base}/web app" path-to-js-file="{base}/web app/main file.js"
|
||||
application-parameters=""{base}/data file"" />"#,
|
||||
),
|
||||
(
|
||||
"Shell",
|
||||
r#"<configuration name="NAME" type="ShConfigurationType">
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="{base}/jobs 中" />
|
||||
<option name="SCRIPT_PATH" value="{base}/jobs 中/run me.sh" />
|
||||
<option name="INTERPRETER_PATH" value="{base}/bin/sh" />
|
||||
</configuration>"#,
|
||||
),
|
||||
];
|
||||
write(&root, "manage.py", "");
|
||||
for (name, body) in cases {
|
||||
write(
|
||||
&root,
|
||||
&format!(".run/{name}.run.xml"),
|
||||
&run_file(name, &body.replace("{base}", "file://$PROJECT_DIR$").replace("{sep}", &SEP.to_string())),
|
||||
);
|
||||
}
|
||||
let r = import_from(&root, &[]);
|
||||
assert!(r.skipped.is_empty(), "{r:#?}");
|
||||
let python = get(&r, "Python");
|
||||
assert_eq!(cwd(python), Some(PathBuf::from("jobs 中")));
|
||||
assert_eq!(python.command, "../bin/python");
|
||||
assert_eq!(python.args, s(&["run me.py", "../data file", "unchanged%20arg"]));
|
||||
assert_eq!(python.extras.env_files, s(&[".env dev", ".env local", ".env plugin"]));
|
||||
assert_eq!(get(&r, "Django").args, s(&["manage.py", "test", "--settings", "settings.dev"]));
|
||||
assert_eq!(get(&r, "Pytest").args, s(&["-m", "pytest", "test file.py", "data file"]));
|
||||
let npm = get(&r, "Npm");
|
||||
assert_eq!(cwd(npm), Some(PathBuf::from("web app")));
|
||||
assert_eq!(npm.command, "pnpm");
|
||||
assert_eq!(Path::new(&npm.extras.params["nodeInterpreter"]), root.join("bin/node"));
|
||||
assert_eq!(get(&r, "Jest").args, s(&["jest", "--config", "jest config.js", "test file.js"]));
|
||||
assert_eq!(get(&r, "Vitest").args, s(&["vitest", "run", "test dir"]));
|
||||
assert_eq!(get(&r, "Node").args, s(&["main file.js", "../data file"]));
|
||||
let shell = get(&r, "Shell");
|
||||
assert_eq!(shell.command, "../bin/sh");
|
||||
assert_eq!(shell.args, s(&["run me.sh"]));
|
||||
assert_eq!(shell.extras.params["path"], "run me.sh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_urls_expand_module_and_home_macros() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
write(
|
||||
&root,
|
||||
".run/Home.run.xml",
|
||||
&run_file(
|
||||
"Home",
|
||||
r#"<configuration name="NAME" type="PythonConfigurationType">
|
||||
<option name="WORKING_DIRECTORY" value="file://$MODULE_DIR$/jobs dir" />
|
||||
<option name="SCRIPT_NAME" value="file://$MODULE_WORKING_DIR$/jobs dir/main.py" />
|
||||
<option name="SDK_HOME" value="file://$USER_HOME$/bin/python" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
let mut environment = env();
|
||||
let home = root.join("home").to_string_lossy().into_owned();
|
||||
environment.insert("HOME".into(), home.clone());
|
||||
environment.insert("USERPROFILE".into(), home);
|
||||
let r = import(&root, RunImportSource::Jetbrains, &[], &environment).unwrap();
|
||||
let home = get(&r, "Home");
|
||||
assert_eq!(home.command, "../home/bin/python");
|
||||
assert_eq!(home.args, s(&["main.py"]));
|
||||
assert_eq!(cwd(home), Some(PathBuf::from("jobs dir")));
|
||||
assert!(home.extras.local);
|
||||
}
|
||||
@@ -523,6 +523,63 @@ pub fn maven_local_repository(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
})
|
||||
}
|
||||
|
||||
fn xml_escape(text: &str) -> String {
|
||||
text.replace('&', "&").replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
/// Inserts `<localRepository>` right after the `<settings>` open tag.
|
||||
fn inject_local_repository(settings_xml: &str, repo: &str) -> Option<String> {
|
||||
let start = settings_xml.find("<settings")?;
|
||||
let end = settings_xml[start..].find('>')? + start;
|
||||
let mut out = String::with_capacity(settings_xml.len() + repo.len() + 40);
|
||||
out.push_str(&settings_xml[..=end]);
|
||||
out.push_str("<localRepository>");
|
||||
out.push_str(&xml_escape(repo));
|
||||
out.push_str("</localRepository>");
|
||||
out.push_str(&settings_xml[end + 1..]);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn write_m2e_settings(root: &Path, text: &str) -> Option<PathBuf> {
|
||||
let file = ide_core::project_dir::path(root, "maven-settings.xml");
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent).ok()?;
|
||||
}
|
||||
match std::fs::read_to_string(&file) {
|
||||
Ok(existing) if existing == text => {}
|
||||
_ => std::fs::write(&file, text).ok()?,
|
||||
}
|
||||
Some(file)
|
||||
}
|
||||
|
||||
/// The user settings file m2e (the JDTLS Maven import) should read: the
|
||||
/// configured `settings.xml` when it already declares `<localRepository>`,
|
||||
/// else a derived copy under `<root>/.openidea/` with the configured local
|
||||
/// repository injected. m2e resolves the local repository from the settings
|
||||
/// XML, not from `-Dmaven.repo.local`, so without this the import downloads
|
||||
/// into `~/.m2/repository` even when the IDE's own `mvn` runs honor the
|
||||
/// configured path (#1739). `None` = m2e's default applies.
|
||||
pub fn maven_user_settings_for_m2e(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
let m = &s.languages.java.maven;
|
||||
if let Some(file) = setting_path(root, &m.user_settings) {
|
||||
let text = std::fs::read_to_string(&file).ok()?;
|
||||
if crate::java::xml_value(&crate::lombok::strip_xml_comments(&text), "localRepository").is_some() {
|
||||
return Some(file);
|
||||
}
|
||||
if let Some(repo) = setting_path(root, &m.local_repository) {
|
||||
let merged = inject_local_repository(&text, &repo.to_string_lossy())?;
|
||||
if let Some(derived) = write_m2e_settings(root, &merged) {
|
||||
return Some(derived);
|
||||
}
|
||||
}
|
||||
return Some(file);
|
||||
}
|
||||
let repo = setting_path(root, &m.local_repository)?;
|
||||
let minimal =
|
||||
format!("<settings><localRepository>{}</localRepository></settings>", xml_escape(&repo.to_string_lossy()));
|
||||
write_m2e_settings(root, &minimal)
|
||||
}
|
||||
|
||||
/// TypeScript `lib` directory for the selected version; `None` = the server's bundled one.
|
||||
pub fn tsdk(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
match s.languages.typescript.version.trim() {
|
||||
@@ -861,18 +918,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
// The jdtls launcher forwards `--jvm-arg=` values to the JVM it starts.
|
||||
let args = s.languages.java.jdtls_vm_args.split_whitespace().map(|a| format!("--jvm-arg={a}"));
|
||||
cfg.args.extend(args);
|
||||
// m2e (the Maven import) reads mirrors, servers and `<localRepository>` from it.
|
||||
if let Some(file) = setting_path(root, &s.languages.java.maven.user_settings) {
|
||||
// m2e (the Maven import) reads mirrors, servers and `<localRepository>`
|
||||
// from the settings file — never from `-Dmaven.repo.local`. The
|
||||
// derived file carries the configured local repository so the import
|
||||
// downloads where the IDE's own `mvn` runs do (#1739).
|
||||
if let Some(file) =
|
||||
maven_user_settings_for_m2e(root, s).or_else(|| setting_path(root, &s.languages.java.maven.user_settings))
|
||||
{
|
||||
let file = file.to_string_lossy();
|
||||
merge_init_options(
|
||||
cfg,
|
||||
json!({ "settings": { "java": { "configuration": { "maven": { "userSettings": file } } } } }),
|
||||
);
|
||||
}
|
||||
// m2e resolves and downloads into `maven.repo.local`; without it the
|
||||
// embedded Maven falls back to ~/.m2 even when the IDE's own `mvn`
|
||||
// runs honor the configured local repository. A `-Dmaven.repo.local`
|
||||
// the user put in their own jdtls VM options wins.
|
||||
// The JVM system property is still forwarded for JVM-level consumers,
|
||||
// but it is the settings file above that m2e honors. A
|
||||
// `-Dmaven.repo.local` the user put in their own jdtls VM options wins.
|
||||
if let Some(repo) = maven_local_repository(root, s) {
|
||||
let arg = format!("-Dmaven.repo.local={}", repo.to_string_lossy());
|
||||
let taken = cfg.args.iter().enumerate().any(|(i, a)| {
|
||||
@@ -1014,6 +1075,8 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,32 @@ fn is_python_name(name: &str) -> bool {
|
||||
|| rest.strip_prefix("3.").is_some_and(|v| !v.is_empty() && v.chars().all(|c| c.is_ascii_digit()))
|
||||
}
|
||||
|
||||
/// Prefixes named by `conda env list`: `base * /opt/conda`, `myenv /opt/conda/envs/myenv`,
|
||||
/// a `--prefix` env shown as a bare path, comment lines skipped (#2240).
|
||||
fn conda_env_list_prefixes(output: &str) -> Vec<PathBuf> {
|
||||
output
|
||||
.lines()
|
||||
.map(|l| l.trim_start_matches(['#', '*', ' ']).trim_end())
|
||||
.filter(|l| !l.is_empty() && !l.starts_with('#'))
|
||||
.filter_map(|l| {
|
||||
// `name /path` or a bare `/path` (a --prefix env shows no name): the path
|
||||
// starts at the first rooted prefix — a drive letter, '/', '~' or '\\\\' —
|
||||
// so names and the stripped "# conda environments:" header never leak, and
|
||||
// paths with spaces (C:\\Program Files\\...) stay whole.
|
||||
let bytes = l.as_bytes();
|
||||
let drive = (0..bytes.len().saturating_sub(2))
|
||||
.filter(|&i| {
|
||||
bytes[i].is_ascii_alphabetic()
|
||||
&& bytes[i + 1] == b':'
|
||||
&& (bytes[i + 2] == b'\\' || bytes[i + 2] == b'/')
|
||||
})
|
||||
.map(Some);
|
||||
let start = [l.find(['/', '~']), l.find("\\\\")].into_iter().chain(drive).flatten().min();
|
||||
start.map(|i| PathBuf::from(l[i..].trim()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
if let Some(root) = root {
|
||||
@@ -155,10 +181,41 @@ fn python(root: Option<&Path>) -> Vec<Sdk> {
|
||||
if let Some(prefix) = std::env::var_os("CONDA_PREFIX") {
|
||||
conda_roots.push(PathBuf::from(prefix));
|
||||
}
|
||||
// A conda on PATH reveals a non-default install root (Anaconda on D:\, Miniconda
|
||||
// in ProgramData): the binary lives at <root>/Scripts/conda.exe (Windows) or
|
||||
// <root>/bin/conda. Without this, an install outside the home-dir guesses keeps
|
||||
// every `envs/` environment invisible (#2240).
|
||||
if let Some(conda) = which("conda") {
|
||||
if let Some(root) = conda.parent().and_then(Path::parent) {
|
||||
conda_roots.push(root.to_path_buf());
|
||||
}
|
||||
// `conda env list` also names `--prefix` envs kept outside `envs/` and envs_dirs
|
||||
// configured in .condarc — every env the user sees on the CLI shows up here.
|
||||
if let Some(list) = query(&conda, &["env", "list"], VERSION_TIMEOUT) {
|
||||
conda_roots.extend(conda_env_list_prefixes(&list));
|
||||
}
|
||||
}
|
||||
let mut seen_roots = HashSet::new();
|
||||
conda_roots.retain(|r| seen_roots.insert(r.clone()));
|
||||
// Conda prefixes keep `python.exe` at the env root on Windows (`envs/x/python.exe`),
|
||||
// not under Scripts/ like a virtualenv — probe every conventional location (#2240).
|
||||
let conda_python = |prefix: &Path| -> Option<PathBuf> {
|
||||
[
|
||||
prefix.join(exe("python")),
|
||||
prefix.join(bin_dir()).join(exe("python")),
|
||||
prefix.join("bin").join(exe("python")),
|
||||
]
|
||||
.into_iter()
|
||||
.find(|p| p.is_file())
|
||||
};
|
||||
for base in conda_roots {
|
||||
found.push((base.join(bin_dir()).join(exe("python")), "conda"));
|
||||
if let Some(p) = conda_python(&base) {
|
||||
found.push((p, "conda"));
|
||||
}
|
||||
for env in subdirs(&base.join("envs")) {
|
||||
found.push((env.join(bin_dir()).join(exe("python")), "conda"));
|
||||
if let Some(p) = conda_python(&env) {
|
||||
found.push((p, "conda"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -872,3 +929,41 @@ mod pixi_tests {
|
||||
assert_eq!(pixi_env_home(Path::new("/p/envs/x/bin/python")), None);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conda_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn conda_env_list_names_named_prefix_and_bare_prefix_envs() {
|
||||
// `conda env list` on Windows with Anaconda on a second drive (#2240).
|
||||
let output = "\
|
||||
# conda environments:
|
||||
#
|
||||
base * D:\\soft\\anaconda3
|
||||
ml D:\\soft\\anaconda3\\envs\\ml
|
||||
D:\\envs\\scratchpad
|
||||
legacy C:\\Users\\dev\\.conda\\envs\\legacy
|
||||
";
|
||||
let prefixes = conda_env_list_prefixes(output);
|
||||
assert_eq!(
|
||||
prefixes,
|
||||
vec![
|
||||
PathBuf::from("D:\\soft\\anaconda3"),
|
||||
PathBuf::from("D:\\soft\\anaconda3\\envs\\ml"),
|
||||
PathBuf::from("D:\\envs\\scratchpad"),
|
||||
PathBuf::from("C:\\Users\\dev\\.conda\\envs\\legacy"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conda_env_list_handles_unix_and_empty_output() {
|
||||
assert!(conda_env_list_prefixes("").is_empty());
|
||||
let output = "# conda environments:\n#\nbase * /opt/miniconda3\nprod /opt/miniconda3/envs/prod\n";
|
||||
assert_eq!(
|
||||
conda_env_list_prefixes(output),
|
||||
vec![PathBuf::from("/opt/miniconda3"), PathBuf::from("/opt/miniconda3/envs/prod")]
|
||||
);
|
||||
}
|
||||
}
|
||||
+103
-36
@@ -279,11 +279,8 @@ pub fn jdtls_runtime(jdks: &[Jdk], min_java: u32) -> Option<&Jdk> {
|
||||
/// for 21, but the Eclipse platform it ships moves on (1.63 needs 25), and on
|
||||
/// an older JDK the server dies resolving its bundles.
|
||||
pub fn jdtls_min_java(cfg: &LanguageServerConfig) -> u32 {
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let required = search
|
||||
.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))
|
||||
.and_then(|exe| dunce::canonicalize(exe).ok())
|
||||
.and_then(|exe| Some(exe.parent()?.parent()?.to_path_buf()))
|
||||
let required = crate::jdtls::script_for(cfg)
|
||||
.and_then(|script| Some(script.parent()?.parent()?.to_path_buf()))
|
||||
.and_then(|home| bundles_min_java(&home));
|
||||
required.map_or(JDTLS_MIN_JAVA, |v| v.max(JDTLS_MIN_JAVA))
|
||||
}
|
||||
@@ -506,21 +503,31 @@ pub fn configure_jdtls(
|
||||
}
|
||||
}
|
||||
let jdks = all.as_slice();
|
||||
if cfg.launch_error.is_none() && !cfg.env.contains_key("JAVA_HOME") {
|
||||
let runtime = match server_jdk {
|
||||
Some(jdk) => server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err),
|
||||
None => jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java)),
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
if cfg.launch_error.is_none() {
|
||||
// A `JAVA_HOME` the config inherits (e.g. the project's JDK 8 for
|
||||
// compilation) must not run jdtls: a chosen `server_jdk`, else any
|
||||
// usable JDK, replaces it when it probes as too old or incomplete for
|
||||
// the server (issue #2227). A `JAVA_HOME` that cannot be probed is the
|
||||
// user's call and is left untouched.
|
||||
let env_jdk = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)));
|
||||
if cfg.env.contains_key("JAVA_HOME") && env_jdk.is_none() {
|
||||
// Unresolvable explicit value: keep it, jdtls reports what it finds.
|
||||
} else {
|
||||
let existing = env_jdk.filter(|jdk| server_jdk_error(jdk, min_java).is_none());
|
||||
let runtime = if let Some(jdk) = server_jdk {
|
||||
server_jdk_error(jdk, min_java).map_or(Ok(jdk), Err)
|
||||
} else if let Some(jdk) = existing.as_ref() {
|
||||
Ok(jdk)
|
||||
} else {
|
||||
jdtls_runtime(jdks, min_java).ok_or_else(|| missing_runtime_message(jdks, min_java))
|
||||
};
|
||||
match runtime {
|
||||
Ok(jdk) => {
|
||||
cfg.env.insert("JAVA_HOME".to_owned(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
}
|
||||
Err(e) => cfg.launch_error = Some(e),
|
||||
}
|
||||
} else if cfg.launch_error.is_none()
|
||||
&& let Some(jdk) = cfg.env.get("JAVA_HOME").and_then(|home| jdk_at(Path::new(home)))
|
||||
{
|
||||
cfg.launch_error = server_jdk_error(&jdk, min_java);
|
||||
}
|
||||
let mut runtimes = runtimes(&project_jdks);
|
||||
if let Some(project) = project_jdk.or_else(|| pick_jdk(&project_jdks, project_java_version(server_root))) {
|
||||
@@ -715,6 +722,10 @@ mod tests {
|
||||
.unwrap();
|
||||
write_test_java(&home);
|
||||
std::fs::write(home.join("bin").join(crate::exe("javac")), "").unwrap();
|
||||
// `jdk_at` reports the canonical home; `configure_jdtls` writes it to
|
||||
// JAVA_HOME, so tests compare against the same real path (macOS
|
||||
// /var→/private/var, Windows 8.3 short names).
|
||||
let home = dunce::canonicalize(home).unwrap();
|
||||
Jdk { major, home }
|
||||
}
|
||||
|
||||
@@ -833,7 +844,7 @@ mod tests {
|
||||
"name": "JavaSE-21", "path": pick_jdk(&installed, None).unwrap().home, "default": true
|
||||
}])
|
||||
);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("custom-server-runtime").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1007,15 +1018,11 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": fixtures.path().join("jdk8") }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21a") }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": fixtures.path().join("jdk25"), "default": true }));
|
||||
assert_eq!(rt[0], json!({ "name": "JavaSE-1.8", "path": jdks[3].home }));
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": jdks[1].home }));
|
||||
assert_eq!(rt[2], json!({ "name": "JavaSE-25", "path": jdks[0].home, "default": true }));
|
||||
assert_eq!(opts["bundles"], json!(["/tools/java-debug/bin/com.microsoft.java.debug.plugin.jar"]));
|
||||
assert_eq!(
|
||||
cfg.env["JAVA_HOME"],
|
||||
fixtures.path().join("jdk25").to_string_lossy(),
|
||||
"first JDK new enough to run jdtls"
|
||||
);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], jdks[0].home.to_string_lossy(), "first JDK new enough to run jdtls");
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
|
||||
// Deterministic (reconfigure compares configs) and idempotent.
|
||||
@@ -1049,8 +1056,8 @@ mod tests {
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone().unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": fixtures.path().join("jdk21b"), "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("jdk21b").to_string_lossy());
|
||||
assert_eq!(rt[1], json!({ "name": "JavaSE-21", "path": project.home, "default": true }));
|
||||
assert_eq!(cfg.env["JAVA_HOME"], project.home.to_string_lossy());
|
||||
|
||||
let mut other: LanguageServerConfig = toml::from_str("name = \"pyright\"\ncommand = \"x\"").unwrap();
|
||||
configure_jdtls(
|
||||
@@ -1222,11 +1229,71 @@ mod tests {
|
||||
assert!(err.contains("Download JDK 25"), "{err}");
|
||||
|
||||
// Once a JDK 25 has been downloaded, Auto runs jdtls on it.
|
||||
let downloaded = [installed.as_slice(), &[test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25")]].concat();
|
||||
let temurin = test_jdk(fixtures.path(), 25, "/tools/jdks/temurin-25");
|
||||
let downloaded = [installed.as_slice(), std::slice::from_ref(&temurin)].concat();
|
||||
let mut cfg = jdtls();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &downloaded, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("tools/jdks/temurin-25").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], temurin.home.to_string_lossy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn issue_2227_a_project_java_home_too_old_for_the_server_is_replaced() {
|
||||
// A project's JAVA_HOME (JDK 8 for compilation) must not run jdtls when
|
||||
// the chosen server_jdk — or any usable JDK — is new enough.
|
||||
let fixtures = tempfile::tempdir().unwrap();
|
||||
let jdtls = || -> LanguageServerConfig { toml::from_str("name = \"jdtls\"\ncommand = \"jdtls\"").unwrap() };
|
||||
let project = test_jdk(fixtures.path(), 8, "/jdk8");
|
||||
let server = test_jdk(fixtures.path(), 25, "/jdk25");
|
||||
|
||||
// The chosen server JDK overrides the project's JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
Some(&server),
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Auto picks any usable installed JDK over the stale JAVA_HOME.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
let installed = [project.clone(), server.clone()];
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// A JAVA_HOME new enough for the server stays.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), server.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &installed, None, None, 25, &[], None);
|
||||
assert_eq!(cfg.launch_error, None);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], server.home.to_string_lossy());
|
||||
|
||||
// Nothing usable to replace it with: the too-old JAVA_HOME loses to the
|
||||
// same missing-runtime error a missing JAVA_HOME gives.
|
||||
let mut cfg = jdtls();
|
||||
cfg.env.insert("JAVA_HOME".into(), project.home.to_string_lossy().into_owned());
|
||||
configure_jdtls(
|
||||
&mut cfg,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
std::slice::from_ref(&project),
|
||||
None,
|
||||
None,
|
||||
25,
|
||||
&[],
|
||||
None,
|
||||
);
|
||||
assert!(cfg.launch_error.unwrap().contains("JDK 25 or newer"), "auto must say what to install");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1288,23 +1355,23 @@ mod tests {
|
||||
let cfg = run(Some(&jdk(21, "/jdk21")), JDTLS_MIN_JAVA);
|
||||
assert_eq!(
|
||||
(cfg.env["JAVA_HOME"].clone(), cfg.launch_error),
|
||||
(fixtures.path().join("jdk21").to_string_lossy().into_owned(), None)
|
||||
(jdk(21, "/jdk21").home.to_string_lossy().into_owned(), None)
|
||||
);
|
||||
let opts = serde_json::to_value(cfg.initialization_options.unwrap()).unwrap();
|
||||
let rt = &opts["settings"]["java"]["configuration"]["runtimes"];
|
||||
assert_eq!(
|
||||
rt[0],
|
||||
json!({ "name": "JavaSE-17", "path": fixtures.path().join("jdk17"), "default": true }),
|
||||
json!({ "name": "JavaSE-17", "path": project.home, "default": true }),
|
||||
"still compiles on 17"
|
||||
);
|
||||
|
||||
// Not set: the first installed JDK new enough, as before.
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], fixtures.path().join("jdk25").to_string_lossy());
|
||||
assert_eq!(run(None, JDTLS_MIN_JAVA).env["JAVA_HOME"], jdk(25, "/jdk25").home.to_string_lossy());
|
||||
|
||||
// A chosen JDK outside the scanned locations.
|
||||
let own = jdk(25, "/opt/own-jdk");
|
||||
let cfg = run(Some(&own), 25);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], fixtures.path().join("opt/own-jdk").to_string_lossy());
|
||||
assert_eq!(cfg.env["JAVA_HOME"], own.home.to_string_lossy());
|
||||
|
||||
// Too old for this jdtls: the user's choice is not overridden, it is explained.
|
||||
let old = jdk(21, "/jdk21");
|
||||
@@ -1441,7 +1508,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.9.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.12.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
+24
-15
@@ -228,6 +228,29 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
|
||||
args
|
||||
}
|
||||
|
||||
/// The release script a `jdtls` command on `PATH` resolves to: itself when it
|
||||
/// already is one, or the script its Scoop shim launches. `None` for a command
|
||||
/// that is neither a release script nor a jdtls Scoop shim.
|
||||
///
|
||||
/// Shared with [`crate::java::jdtls_min_java`], which needs the same install
|
||||
/// root to read the bundles' required Java version.
|
||||
pub(crate) fn script_for(cfg: &LanguageServerConfig) -> Option<PathBuf> {
|
||||
if cfg.name != "jdtls" {
|
||||
return None;
|
||||
}
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let script = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s))?;
|
||||
let resolved = dunce::canonicalize(script).ok()?;
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => Some(resolved),
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release
|
||||
// layout behind itself (issue #794); follow its `.shim` to the script.
|
||||
Some("jdtls.exe") if cfg!(windows) => shim_target(&resolved),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Scoop shims either point at the release script or run Python with the
|
||||
/// script in `args`. Paths may be quoted and contain spaces and backslashes.
|
||||
fn shim_target(shim_exe: &Path) -> Option<PathBuf> {
|
||||
@@ -263,21 +286,7 @@ pub fn launch_directly(cfg: &mut LanguageServerConfig) {
|
||||
if cfg.name != "jdtls" || cfg.launch_error.is_some() {
|
||||
return;
|
||||
}
|
||||
let search = cfg.env.get("PATH").map(std::ffi::OsString::from).or_else(|| std::env::var_os("PATH"));
|
||||
let Some(script) = search.and_then(|s| ide_run::which::find_executable(&cfg.command, &s)) else { return };
|
||||
let Ok(resolved) = dunce::canonicalize(&script) else { return };
|
||||
let name = resolved.file_name().map(|n| n.to_string_lossy().to_ascii_lowercase());
|
||||
let script = match name.as_deref() {
|
||||
Some(n) if SCRIPTS.contains(&n) => resolved,
|
||||
// A scoop shim (`shims\jdtls.exe` from PATHEXT) hides the release layout
|
||||
// behind itself, so the launch ran the shim's own command line and the
|
||||
// configured JDK never applied (issue #794).
|
||||
Some("jdtls.exe") if cfg!(windows) => match shim_target(&resolved) {
|
||||
Some(target) => target,
|
||||
None => return,
|
||||
},
|
||||
_ => return,
|
||||
};
|
||||
let Some(script) = script_for(cfg) else { return };
|
||||
let Some(install) =
|
||||
jdtls_home(&script).and_then(|h| find_install(&h, std::env::consts::OS, std::env::consts::ARCH))
|
||||
else {
|
||||
|
||||
@@ -41,6 +41,8 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
tsserver_project_extras: Default::default(),
|
||||
resync_on_configuration: false,
|
||||
pinned: None,
|
||||
target: None,
|
||||
target_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -206,6 +208,53 @@ fn jdtls_honors_the_configured_maven_local_repository() {
|
||||
assert!(cfg.args.iter().all(|a| !a.contains("maven.repo.local")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_m2e_gets_effective_settings_with_local_repository() {
|
||||
fn user_settings(cfg: &LanguageServerConfig) -> Option<std::path::PathBuf> {
|
||||
let opts = serde_json::to_value(cfg.initialization_options.clone()?).ok()?;
|
||||
opts.pointer("/settings/java/configuration/maven/userSettings")?.as_str().map(std::path::PathBuf::from)
|
||||
}
|
||||
|
||||
// Configured repository, no user settings: a derived file is passed (#1739).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo = tmp.path().join("m2-repo");
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.local_repository = repo.to_string_lossy().into_owned();
|
||||
let mut cfg = server("jdtls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let derived = tmp.path().join(".openidea").join("maven-settings.xml");
|
||||
assert_eq!(user_settings(&cfg), Some(derived.clone()));
|
||||
let text = std::fs::read_to_string(&derived).unwrap();
|
||||
assert!(text.contains(&format!("<localRepository>{}</localRepository>", repo.to_string_lossy())));
|
||||
// Stable across runs (no rewrite churn).
|
||||
let mut cfg = server("jdtls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(user_settings(&cfg), Some(derived.clone()));
|
||||
|
||||
// User settings without `<localRepository>`: the derived copy keeps the
|
||||
// user's mirrors and gains the configured repository.
|
||||
let settings = tmp.path().join("mine.xml");
|
||||
std::fs::write(&settings, "<settings><mirrors><mirror><id>m</id></mirror></mirrors></settings>").unwrap();
|
||||
s.languages.java.maven.user_settings = settings.to_string_lossy().into_owned();
|
||||
let mut cfg = server("jdtls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(user_settings(&cfg), Some(derived.clone()));
|
||||
let text = std::fs::read_to_string(&derived).unwrap();
|
||||
assert!(text.contains("<mirror><id>m</id></mirror>"));
|
||||
assert!(text.contains(&format!("<localRepository>{}</localRepository>", repo.to_string_lossy())));
|
||||
|
||||
// User settings already declaring `<localRepository>`: passed through untouched.
|
||||
std::fs::write(&settings, "<settings><localRepository>/user/repo</localRepository></settings>").unwrap();
|
||||
let mut cfg = server("jdtls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(user_settings(&cfg), Some(settings.clone()));
|
||||
|
||||
// Neither configured: no userSettings at all.
|
||||
let mut cfg = server("jdtls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &Settings::default());
|
||||
assert_eq!(user_settings(&cfg), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_analyzer_check_command_and_toolchain() {
|
||||
if cfg!(windows) {
|
||||
|
||||
@@ -179,6 +179,9 @@ fn normalize_edit(edit: &WorkspaceEdit) -> IdeResult<WorkspaceEdit> {
|
||||
let mut files = Vec::new();
|
||||
for file in &edit.files {
|
||||
let resolve = |path: &Path, text: bool| -> IdeResult<PathBuf> {
|
||||
if ide_core::is_virtual_uri(&path.to_string_lossy()) {
|
||||
return Err(IdeError::invalid("library documents are always read-only"));
|
||||
}
|
||||
if !changed.is_empty() {
|
||||
return after_resources(path, &changed, 0);
|
||||
}
|
||||
|
||||
@@ -191,6 +191,8 @@ impl SettingsStore {
|
||||
let Some(mut layer) = read_layer(&file) else { continue };
|
||||
if scope == SettingsScope::Project {
|
||||
remove_global_settings(&mut layer);
|
||||
} else if let Some(layer) = layer.as_object_mut() {
|
||||
layer.remove("languageServerTargets");
|
||||
}
|
||||
if let Err(e) = try_merge(&mut merged, layer) {
|
||||
tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display());
|
||||
@@ -216,6 +218,11 @@ impl SettingsStore {
|
||||
/// `null` removes the key from that layer. Unknown keys and values of the
|
||||
/// wrong type are rejected with `InvalidInput`; the file is left untouched.
|
||||
pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult<Settings> {
|
||||
if scope == SettingsScope::Global
|
||||
&& (key == "languageServerTargets" || key.starts_with("languageServerTargets."))
|
||||
{
|
||||
return Err(IdeError::invalid("language server targets belong to project settings"));
|
||||
}
|
||||
if scope == SettingsScope::Project
|
||||
&& (key == "editor.customSchemes"
|
||||
|| key.starts_with("editor.customSchemes.")
|
||||
@@ -677,4 +684,26 @@ mod tests {
|
||||
.enabled
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn language_server_targets_are_project_only_and_default_to_the_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
std::fs::write(config.join("settings.toml"), "[languageServerTargets.php]\ntarget = 'global-target'\n")
|
||||
.unwrap();
|
||||
let settings = SettingsStore::load(&config, Some(&project));
|
||||
assert!(settings.effective().language_server_targets.is_empty());
|
||||
assert!(settings.set(SettingsScope::Global, "languageServerTargets", json!({})).is_err());
|
||||
let selected = settings
|
||||
.set(
|
||||
SettingsScope::Project,
|
||||
"languageServerTargets",
|
||||
json!({"php":{"target":"container","server":"intelephense"}}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(selected.language_server_targets["php"].target, "container");
|
||||
assert_eq!(settings.reload().language_server_targets["php"].server, "intelephense");
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 377 KiB After Width: | Height: | Size: 86 KiB |
@@ -0,0 +1,42 @@
|
||||
# Open scripts from Unity
|
||||
|
||||
OpenIDEA can open scripts and Console source locations without a Unity plugin.
|
||||
|
||||
1. In Unity, open **Edit > Preferences > External Tools** (on macOS,
|
||||
**Unity > Settings/Preferences > External Tools**).
|
||||
2. Under **External Script Editor**, choose **Browse** and select OpenIDEA's
|
||||
executable (or its `.app` on macOS).
|
||||
3. Set **External Script Editor Args** to:
|
||||
|
||||
```text
|
||||
$(ProjectPath) --file $(File) --line $(Line) --column $(Column)
|
||||
```
|
||||
|
||||
Unity 2022.3 quotes the expanded project and file paths itself, including paths
|
||||
with spaces. Do not add another pair of quotes around these placeholders.
|
||||
|
||||
Double-click a script in the Project window or a source location in the Console.
|
||||
OpenIDEA opens the project and file, or focuses its existing project window, then
|
||||
places the caret at the supplied location. Other open tabs stay in place.
|
||||
|
||||
The project argument makes cold launches open the Unity project rather than a
|
||||
standalone file. Without it, `--file` opens in an existing matching project window
|
||||
or in LightEdit. Relative file paths resolve against the supplied project folder,
|
||||
or against the launch working directory when no project is supplied.
|
||||
|
||||
For a direct invocation of the application executable, quote paths as required
|
||||
by your shell. The separately installed `openidea` command has its own syntax:
|
||||
|
||||
```text
|
||||
/path/to/openidea "/path/to/My Game" --file "/path/to/My Game/Assets/Player.cs" --line 12 --column 5
|
||||
```
|
||||
|
||||
`--file=...`, `--line=...` and `--column=...` also work. Lines and columns are
|
||||
1-based; Unity's `0` and `-1` mean an unknown position. An omitted or unknown
|
||||
column uses the start of the line. Without a known line, the file opens without
|
||||
an explicit caret position. Invalid arguments and missing files report an error.
|
||||
|
||||
On Windows, use the same permissions for Unity and OpenIDEA when possible.
|
||||
If a running instance does not receive a request, restart both applications with
|
||||
matching permissions and retry. This integration opens local projects; it does
|
||||
not generate C# project files or attach a debugger.
|
||||
@@ -340,3 +340,23 @@ test("selects through code blocks and tables while keeping copy controls outside
|
||||
await copies.last().click();
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(flow);
|
||||
});
|
||||
|
||||
test("keeps mermaid `<style>` rules scoped to the rendered svg after id rewriting", async ({ page }) => {
|
||||
const diagram = [
|
||||
"```mermaid",
|
||||
"%%{init: {'theme': 'base', 'flowchart': {'htmlLabels': false, 'curve': 'basis'}}}%%",
|
||||
"flowchart TB",
|
||||
' subgraph T1["Tier 1"]',
|
||||
" A[(Local)] <--> B[(Peer)]",
|
||||
" end",
|
||||
' subgraph T2["Tier 2"]',
|
||||
" C[(Host RAM)]",
|
||||
" end",
|
||||
' C -->|"PCIe"| A',
|
||||
"```",
|
||||
].join("\n");
|
||||
const reply = await openReply(page, `Diagram:\n\n${diagram}`);
|
||||
await expect(reply.locator("[data-mermaid-state='ok'] .mermaid-output svg")).toBeVisible();
|
||||
expect(await css(reply, ".cluster rect", "fill")).not.toBe("rgb(0, 0, 0)");
|
||||
expect(await css(reply, ".flowchart-link", "fill")).toBe("none");
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
/** The end of a long line must stay visible: the floating corner widgets leave it room. */
|
||||
test("the inspection widget does not cover the end of a long line", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
const content = editor.locator(".cm-content");
|
||||
await content.click();
|
||||
await page.keyboard.press("Meta+a");
|
||||
await page.keyboard.insertText("x".repeat(600));
|
||||
await expect(editor.locator(".cm-line")).toHaveCount(1);
|
||||
await expect(editor.locator(".cm-openidea-corner")).toBeAttached();
|
||||
|
||||
const scroller = editor.locator(".cm-scroller");
|
||||
await expect.poll(() => scroller.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeGreaterThan(0);
|
||||
await scroller.evaluate((el) => {
|
||||
el.scrollLeft = el.scrollWidth;
|
||||
});
|
||||
|
||||
const rects = await editor.evaluate((el) => {
|
||||
const line = el.querySelector(".cm-line")!;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(line);
|
||||
return {
|
||||
textRight: range.getBoundingClientRect().right,
|
||||
cornerLeft: el.querySelector(".cm-openidea-corner")!.getBoundingClientRect().left,
|
||||
scrollLeft: el.querySelector(".cm-scroller")!.scrollLeft,
|
||||
};
|
||||
});
|
||||
// Scrolled as far right as it goes, and the last character sits left of the widget.
|
||||
expect(rects.scrollLeft).toBeGreaterThan(0);
|
||||
expect(rects.cornerLeft).toBeGreaterThan(rects.textRight);
|
||||
});
|
||||
@@ -18,6 +18,7 @@ interface Opts {
|
||||
alignChanges?: boolean;
|
||||
syncScroll?: boolean;
|
||||
collapse?: boolean;
|
||||
softWrap?: boolean;
|
||||
ignore?: "none" | "trim" | "whitespace" | "whitespaceAndLines";
|
||||
}
|
||||
|
||||
@@ -350,3 +351,126 @@ test.describe("10,000-line files", () => {
|
||||
await shot(page, `rewrite-${mode.replace(" ", "-")}`);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("soft wrapping", () => {
|
||||
for (const [mode, opts] of [
|
||||
["split", { alignChanges: false }],
|
||||
["aligned", { alignChanges: true }],
|
||||
["unified", { mode: "unified" }],
|
||||
] as [string, Opts][]) {
|
||||
test(`toggles in place, retains the visible line and routes the editor action (${mode})`, async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await openCase(page, "src/soft-wrap.ts", { ...opts, softWrap: false });
|
||||
const editors = page.locator(".cm-editor.cm-merge-a, .cm-editor.cm-merge-b");
|
||||
const count = mode === "unified" ? 1 : 2;
|
||||
await expect(editors).toHaveCount(count);
|
||||
await editors.evaluateAll((eds) => {
|
||||
for (const ed of eds) (ed as HTMLElement).dataset.wrapIdentity = "original";
|
||||
});
|
||||
const right = page.locator(".cm-editor.cm-merge-b");
|
||||
await right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const view = EditorView.findFromDOM(ed)!;
|
||||
const pos = view.state.doc.line(125).from;
|
||||
view.dispatch({ selection: { anchor: pos }, effects: EditorView.scrollIntoView(pos, { y: "center" }) });
|
||||
view.focus();
|
||||
});
|
||||
const topLine = () =>
|
||||
right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const v = EditorView.findFromDOM(ed)!;
|
||||
const outer = ed.closest<HTMLElement>(".cm-mergeView");
|
||||
const top = Math.max(v.scrollDOM.getBoundingClientRect().top, outer?.getBoundingClientRect().top ?? 0);
|
||||
return v.state.doc.lineAt(v.lineBlockAtHeight(Math.max(0, top - v.documentTop)).from).number;
|
||||
});
|
||||
await expect.poll(topLine).toBeGreaterThan(80);
|
||||
const before = await topLine();
|
||||
await toolbar(page).getByRole("button", { name: "Settings", exact: true }).click();
|
||||
const checkbox = page.getByRole("menuitemcheckbox", { name: "Use Soft Wraps", exact: true });
|
||||
await expect(checkbox).toHaveAttribute("aria-checked", "false");
|
||||
await checkbox.click();
|
||||
await expect(page.locator(".cm-lineWrapping")).toHaveCount(count);
|
||||
await expect.poll(async () => Math.abs((await topLine()) - before)).toBeLessThanOrEqual(3);
|
||||
await expect(page.locator('[data-wrap-identity="original"]')).toHaveCount(count);
|
||||
await right.evaluate(async (ed) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
const v = EditorView.findFromDOM(ed)!;
|
||||
if (v.state.doc.lineAt(v.state.selection.main.head).number !== 125) throw new Error("Selection lost");
|
||||
v.dispatch({ effects: EditorView.scrollIntoView(0, { y: "start" }) });
|
||||
});
|
||||
const first = right.locator(".cm-content > .cm-line").first();
|
||||
await expect.poll(() => first.evaluate((line) => line.getBoundingClientRect().height)).toBeGreaterThan(100);
|
||||
// Line-number backgrounds cover the entire wrapped logical line.
|
||||
if (mode !== "unified") {
|
||||
await expect
|
||||
.poll(() =>
|
||||
right.evaluate((ed) =>
|
||||
Math.abs(
|
||||
ed.querySelector(".cm-content > .cm-line")!.getBoundingClientRect().height -
|
||||
ed.querySelector(".cm-lineNumbers .cm-diffGutter-modified")!.getBoundingClientRect().height,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toBeLessThan(2);
|
||||
if (mode === "aligned") {
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const tops = await editors.evaluateAll(async (eds) => {
|
||||
const path = "/node_modules/.vite/deps/@codemirror_view.js";
|
||||
const { EditorView } = await import(/* @vite-ignore */ path);
|
||||
return eds.map((ed) => {
|
||||
const view = EditorView.findFromDOM(ed)!;
|
||||
return view.documentTop + view.lineBlockAt(view.state.doc.line(2).from).bottom;
|
||||
});
|
||||
});
|
||||
return Math.abs(tops[0]! - tops[1]!);
|
||||
})
|
||||
.toBeLessThan(2);
|
||||
} else {
|
||||
const path = await page.locator(".cm-diffDivider svg path").first().getAttribute("d");
|
||||
expect(path).toBeTruthy();
|
||||
expect(path).not.toMatch(/NaN|Infinity/);
|
||||
}
|
||||
} else {
|
||||
await expect
|
||||
.poll(() =>
|
||||
right
|
||||
.locator(".cm-deletedLine")
|
||||
.first()
|
||||
.evaluate((line) => line.getBoundingClientRect().height),
|
||||
)
|
||||
.toBeGreaterThan(100);
|
||||
}
|
||||
// The standard action toggles both panes, whether the read-only left or right is focused.
|
||||
for (let i = 0; i < count; i++) {
|
||||
await editors.nth(i).locator(".cm-content").focus();
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/platform/actions.ts";
|
||||
const { executeAction, isActionEnabled, useActions } = await import(/* @vite-ignore */ path);
|
||||
if (!isActionEnabled(useActions.getState().actions.get("editor.toggleSoftWrap")!))
|
||||
throw new Error("Soft-Wrap action disabled");
|
||||
if (document.activeElement?.closest(".cm-merge-a")) {
|
||||
const path = "/src/ui/index.ts";
|
||||
const { showContextMenu } = await import(/* @vite-ignore */ path);
|
||||
showContextMenu({ x: 100, y: 100 }, [{ kind: "action", action: "editor.toggleSoftWrap" }]);
|
||||
} else await executeAction("editor.toggleSoftWrap");
|
||||
});
|
||||
if (count === 2 && i === 0) {
|
||||
const action = page.getByRole("menuitemcheckbox", { name: "Soft-Wrap", exact: true });
|
||||
await expect(action).toBeEnabled();
|
||||
await expect(action).toHaveAttribute("aria-checked", "true");
|
||||
await action.click();
|
||||
}
|
||||
await expect(page.locator(".cm-lineWrapping")).toHaveCount(i % 2 === 0 ? 0 : count);
|
||||
}
|
||||
const persisted = await page.evaluate(
|
||||
() => JSON.parse(localStorage.getItem("openidea.vcs.diffOptions")!).softWrap,
|
||||
);
|
||||
expect(persisted).toBe(count === 2);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -61,6 +61,17 @@ const FUNCS = ["parse", "format", "validate", "render", "save"].map(
|
||||
const MINIFIED = Array.from({ length: 2_000 }, (_, i) => `var a${i}=f(${i},"k${i}");`).join("");
|
||||
|
||||
export const CASES: readonly DiffCase[] = [
|
||||
{
|
||||
path: "src/soft-wrap.ts",
|
||||
before:
|
||||
`// ${"long original text ".repeat(100)}\n` +
|
||||
lines(150, (i) => `const value${i} = ${i};`) +
|
||||
`// ${"old tail ".repeat(100)}\n`,
|
||||
after:
|
||||
`// ${"long changed text ".repeat(160)}\n` +
|
||||
lines(150, (i) => `const value${i} = ${i};`) +
|
||||
`// ${"new changed tail ".repeat(180)}\n`,
|
||||
},
|
||||
// A word edited and a comment added: one modified block (the reported case).
|
||||
{ path: "src/options.ts", before: OPTIONS_BEFORE, after: OPTIONS_AFTER },
|
||||
{
|
||||
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user