дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-14 21:55:44 +02:00
Порівняти коміти
113
Коміти
| Автор | SHA1 | Дата | |
|---|---|---|---|
|
|
7c9ff885dd | ||
|
|
4e90d9fd5c | ||
|
|
ffc2f75d3b | ||
|
|
19587af8b1 | ||
|
|
b2c22ccec6 | ||
|
|
f795aa3232 | ||
|
|
9239b99f3a | ||
|
|
c5be2998ec | ||
|
|
b83df2dbda | ||
|
|
9f7a35f170 | ||
|
|
52b3e14a8c | ||
|
|
934e4ca0e7 | ||
|
|
a2809bda80 | ||
|
|
0fd8389b33 | ||
|
|
19c0ae07f2 | ||
|
|
5a5b53d86b | ||
|
|
ac7a81db1c | ||
|
|
9681e1a118 | ||
|
|
293972baaa | ||
|
|
b173a53004 | ||
|
|
f6e54f581b | ||
|
|
276a30849c | ||
|
|
63abedaadd | ||
|
|
c22a7fa112 | ||
|
|
a46f187428 | ||
|
|
ff826279f2 | ||
|
|
759fb3278d | ||
|
|
f7d19e1711 | ||
|
|
97d202ade0 | ||
|
|
f0f15366e4 | ||
|
|
fecbf8ed14 | ||
|
|
2d00f09393 | ||
|
|
f154bf8c83 | ||
|
|
d3720c0e11 | ||
|
|
ec862b3668 | ||
|
|
735830d642 | ||
|
|
65c0413dbb | ||
|
|
14bb7bcc6f | ||
|
|
8113d101ef | ||
|
|
5f7e05adc4 | ||
|
|
f2b85eca4c | ||
|
|
e41cf32a94 | ||
|
|
0cd19a8c0f | ||
|
|
fb76a6dab7 | ||
|
|
f4502ffffe | ||
|
|
2430c892ba | ||
|
|
8876d04cb8 | ||
|
|
eeb20f467c | ||
|
|
4ccff88ccf | ||
|
|
fdf29989ec | ||
|
|
3c3b6a6fd1 | ||
|
|
49bb6b2d76 | ||
|
|
632e564d58 | ||
|
|
08333bcff8 | ||
|
|
ae542b3044 | ||
|
|
4cb98ff162 | ||
|
|
cc8ccea888 | ||
|
|
75344c21ef | ||
|
|
91ac7f417c | ||
|
|
2fd43aa29f | ||
|
|
5b7d258adb | ||
|
|
fd1cebdf98 | ||
|
|
c9362247d0 | ||
|
|
8ab9c688ac | ||
|
|
ba0b3d9c7d | ||
|
|
96f8ee27dd | ||
|
|
5529db667c | ||
|
|
9c55c28d5b | ||
|
|
009773c0d4 | ||
|
|
ba0f4329ea | ||
|
|
4d88d44616 | ||
|
|
5e5d804b89 | ||
|
|
83b9744a36 | ||
|
|
4ab8959bac | ||
|
|
d715643ff2 | ||
|
|
a5214cae38 | ||
|
|
54dd3a167f | ||
|
|
0938a507da | ||
|
|
a05a40e0a3 | ||
|
|
3b978721e3 | ||
|
|
9793857024 | ||
|
|
8fb3b7b084 | ||
|
|
22fe1702be | ||
|
|
ad434af78c | ||
|
|
6d103df5cf | ||
|
|
e2d089a0c4 | ||
|
|
47fa0e6f79 | ||
|
|
921f1f2b2d | ||
|
|
88f73630a6 | ||
|
|
504ffac10a | ||
|
|
5ee558d1a7 | ||
|
|
c09fb7191a | ||
|
|
7ee80ed736 | ||
|
|
353768a15f | ||
|
|
5e969f0bad | ||
|
|
6f60f55a1d | ||
|
|
8e5ac1dc01 | ||
|
|
4fcf4555a1 | ||
|
|
40494f2920 | ||
|
|
c016007a2d | ||
|
|
69ce1ab13d | ||
|
|
fcc8583f36 | ||
|
|
b368f21eb6 | ||
|
|
929866bbc5 | ||
|
|
3c781296d6 | ||
|
|
4aed449dc0 | ||
|
|
55431f3965 | ||
|
|
d4b14a36c7 | ||
|
|
d2c565b19d | ||
|
|
01a60ef164 | ||
|
|
b0b768d25e | ||
|
|
3240bfc423 | ||
|
|
15182507cf |
No files matched your search
@@ -7,7 +7,7 @@ BASEDPYRIGHT=1.40.2
|
||||
RUFF=0.17.3
|
||||
GOPLS=v0.24.0
|
||||
JDTLS=1.63.0-202610191322
|
||||
KOTLIN_LSP=263.7084.0
|
||||
KOTLIN_LSP=271.2085.0
|
||||
GRADLE=8.14.3
|
||||
INTELEPHENSE=1.18.5
|
||||
# The JDK projects compile against (JAVA_HOME): library navigation opens its sources.
|
||||
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
- if: matrix.language == 'java'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact maven_sync jdk maven_launch_classpath_uses_the_runner_jdk_classifier
|
||||
- if: matrix.language == 'kotlin'
|
||||
run: cargo test --locked -p ide-host --test project_smoke -- --ignored --exact gradle_sync
|
||||
- if: matrix.language == 'python'
|
||||
|
||||
Створено
+44
-44
@@ -4144,7 +4144,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4168,7 +4168,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4182,7 +4182,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4193,7 +4193,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"chardetng",
|
||||
@@ -4211,7 +4211,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
@@ -4225,7 +4225,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"objc2 0.6.5",
|
||||
@@ -4235,7 +4235,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4252,7 +4252,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4266,7 +4266,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4293,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bb8",
|
||||
@@ -4329,7 +4329,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4342,7 +4342,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4363,7 +4363,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4375,7 +4375,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4388,7 +4388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-heap"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4406,7 +4406,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4477,7 +4477,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vfs",
|
||||
@@ -4496,7 +4496,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4518,7 +4518,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jdwp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-jvm-inspect",
|
||||
"proptest",
|
||||
@@ -4530,7 +4530,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4547,7 +4547,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-attach"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"tempfile",
|
||||
@@ -4556,7 +4556,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jvm-inspect"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
@@ -4567,7 +4567,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4624,7 +4624,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4652,7 +4652,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
@@ -4671,7 +4671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4694,7 +4694,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4717,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jvm-attach",
|
||||
@@ -4731,7 +4731,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4751,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4775,7 +4775,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-scaffold"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-tools",
|
||||
@@ -4791,7 +4791,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4812,7 +4812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"encoding_rs",
|
||||
@@ -4833,7 +4833,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4846,7 +4846,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4871,7 +4871,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4886,7 +4886,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4899,7 +4899,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4912,7 +4912,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4933,7 +4933,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4954,7 +4954,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4978,7 +4978,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"ide-core",
|
||||
@@ -4994,7 +4994,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6595,7 +6595,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.44"
|
||||
version = "0.1.45"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -144,6 +144,13 @@ fn bracket_pos(s: &str) -> Option<(u32, Option<u32>, &str)> {
|
||||
Some((line, col, rest.trim_start()))
|
||||
}
|
||||
|
||||
/// `/C:/p/A.java` → `C:/p/A.java`: Maven and file URIs put a `/` before the drive letter on Windows.
|
||||
/// Joined as is, it would become the drive-relative `C:p/A.java`.
|
||||
fn drive_path(p: &str) -> &str {
|
||||
let b = p.as_bytes();
|
||||
if b.len() > 2 && b[0] == b'/' && b[1].is_ascii_alphabetic() && b[2] == b':' { &p[1..] } else { p }
|
||||
}
|
||||
|
||||
/// `file:///p/A.kt` → `/p/A.kt` (percent-decoded).
|
||||
fn from_file_uri(s: &str) -> String {
|
||||
let Some(rest) = s.strip_prefix("file://") else { return s.to_owned() };
|
||||
@@ -162,9 +169,7 @@ fn from_file_uri(s: &str) -> String {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
let path = String::from_utf8_lossy(&out).into_owned();
|
||||
// `file:///C:/x` on Windows.
|
||||
if path.len() > 2 && path.as_bytes()[0] == b'/' && path.as_bytes()[2] == b':' { path[1..].to_owned() } else { path }
|
||||
drive_path(&String::from_utf8_lossy(&out)).to_owned()
|
||||
}
|
||||
|
||||
/// Kotlin: `file:///p/A.kt:12:5 msg` or `/p/A.kt: (12, 5): msg`, after the `e: ` / `w: ` prefix.
|
||||
@@ -222,7 +227,7 @@ fn maven_line(line: &str) -> Option<RawMessage> {
|
||||
&& looks_like_source_path(path)
|
||||
&& let Some((ln, col, msg)) = bracket_pos(&format!("[{pos}"))
|
||||
{
|
||||
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(path, ln, col));
|
||||
return Some(RawMessage::new(severity, msg, ColUnit::Char).at(drive_path(path), ln, col));
|
||||
}
|
||||
if let Some(m) = kotlin_line(rest) {
|
||||
return Some(m);
|
||||
@@ -456,6 +461,20 @@ mod tests {
|
||||
assert_eq!(out[1].unit, ColUnit::Char);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_windows_paths_drop_the_slash_before_the_drive() {
|
||||
let out = parse(
|
||||
Format::Maven,
|
||||
"[ERROR] /C:/dev/p/src/main/java/a/A.java:[18,41] package a.b does not exist\n\
|
||||
[ERROR] C:\\dev\\p\\B.java:[3,8] cannot find symbol\n\
|
||||
[ERROR] file:///C:/dev/p/K.kt:4:9 Unresolved reference 'x'.\n",
|
||||
);
|
||||
assert_eq!(out.len(), 3, "{out:?}");
|
||||
assert_eq!(pos(&out[0]), (Severity::Error, "C:/dev/p/src/main/java/a/A.java", 18, Some(41)));
|
||||
assert_eq!(pos(&out[1]), (Severity::Error, "C:\\dev\\p\\B.java", 3, Some(8)));
|
||||
assert_eq!(pos(&out[2]), (Severity::Error, "C:/dev/p/K.kt", 4, Some(9)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_detail_lines_with_level_prefix_and_kotlin() {
|
||||
let out = parse(
|
||||
|
||||
@@ -291,6 +291,56 @@ pub enum DebugEvent {
|
||||
JvmBackend {
|
||||
state: JvmBackendState,
|
||||
},
|
||||
/// A step in the life of one request of a debugged page.
|
||||
JsNetwork {
|
||||
event: JsNetworkEvent,
|
||||
},
|
||||
/// The adapter does not report the requests of this page: there will be no `JsNetwork`.
|
||||
JsNetworkUnavailable,
|
||||
}
|
||||
|
||||
/// Which step of a request a [`JsNetworkEvent`] reports.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JsNetworkKind {
|
||||
Request,
|
||||
Response,
|
||||
Finished,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// The script frame that made a request, where the browser ran it (0-based line and column).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsInitiator {
|
||||
pub function: String,
|
||||
pub url: String,
|
||||
pub line: u32,
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// What a debugged page's browser reports about a request, reduced to what the Network view
|
||||
/// shows. Events of one request share `request_id`; each kind fills its own fields.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsNetworkEvent {
|
||||
pub kind: JsNetworkKind,
|
||||
pub request_id: String,
|
||||
/// The browser's monotonic clock, comparable between the events of a page.
|
||||
pub timestamp_ms: u64,
|
||||
/// Request and response.
|
||||
pub url: Option<String>,
|
||||
pub method: Option<String>,
|
||||
/// `Fetch`, `XHR`, `Script`, `Document`, …
|
||||
pub resource_type: Option<String>,
|
||||
pub status: Option<u16>,
|
||||
pub mime_type: Option<String>,
|
||||
/// The request's headers, or the response's.
|
||||
pub headers: BTreeMap<String, String>,
|
||||
/// Bytes received over the network, once finished.
|
||||
pub encoded_length: Option<u64>,
|
||||
pub error_text: Option<String>,
|
||||
pub initiator: Option<JsInitiator>,
|
||||
}
|
||||
|
||||
/// The backend that reads a debugged JVM's objects (instances, fields, referrers).
|
||||
|
||||
@@ -291,7 +291,7 @@ pub enum NewTaskApprovals {
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct McpServerSettings {
|
||||
/// Serve the tools on 127.0.0.1 while the IDE runs.
|
||||
/// Serve the tools on [`Self::address`] while the IDE runs.
|
||||
pub enabled: bool,
|
||||
/// Run the tools that change the project or start processes without asking first
|
||||
/// (IDEA's brave mode). Read from the IDE's own settings only, never a project's.
|
||||
@@ -300,6 +300,9 @@ pub struct McpServerSettings {
|
||||
pub agent_tab_limit: u32,
|
||||
/// Archived agent tabs kept; older ones are closed.
|
||||
pub agent_archive_limit: u32,
|
||||
/// The IP address to listen on: `127.0.0.1` (this computer only, the default), another
|
||||
/// address of this computer, or `0.0.0.0` / `::` for every address.
|
||||
pub address: String,
|
||||
/// Listen on [`Self::port`] in every run, rather than a free port each time, so HTTP clients and
|
||||
/// port forwards keep working after a restart.
|
||||
pub fixed_port: bool,
|
||||
@@ -312,6 +315,8 @@ pub struct McpServerSettings {
|
||||
|
||||
/// The default of [`McpServerSettings::port`].
|
||||
pub const DEFAULT_MCP_PORT: u16 = 26371;
|
||||
/// The default of [`McpServerSettings::address`]: this computer only.
|
||||
pub const DEFAULT_MCP_ADDRESS: &str = "127.0.0.1";
|
||||
|
||||
impl Default for McpServerSettings {
|
||||
fn default() -> Self {
|
||||
@@ -320,6 +325,7 @@ impl Default for McpServerSettings {
|
||||
run_without_confirmation: false,
|
||||
agent_tab_limit: 1,
|
||||
agent_archive_limit: 999,
|
||||
address: DEFAULT_MCP_ADDRESS.to_owned(),
|
||||
fixed_port: true,
|
||||
port: DEFAULT_MCP_PORT,
|
||||
fixed_token: false,
|
||||
@@ -1153,6 +1159,9 @@ pub struct NodeSettings {
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
/// Runtime for running JavaScript / TypeScript files: `node` | `bun` | `deno`; empty = from the
|
||||
/// project (`deno.json` → Deno, `bun.lock` → Bun, else Node.js).
|
||||
pub runtime: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -1349,11 +1358,15 @@ pub struct TypeScriptSettings {
|
||||
/// `bundled` (the server's own TypeScript), `workspace` (`node_modules/typescript`),
|
||||
/// or a path to a TypeScript `lib` directory.
|
||||
pub version: String,
|
||||
/// tsserver heap limit in MB. `0` = automatic: 8192 when vtsls hosts the Vue
|
||||
/// plugin (hybrid mode; a Vue project's program alone needs ~3 GiB, and the
|
||||
/// vtsls default 3072 dies in a GC death spiral), else unset (vtsls default).
|
||||
pub tsserver_max_memory: u32,
|
||||
}
|
||||
|
||||
impl Default for TypeScriptSettings {
|
||||
fn default() -> Self {
|
||||
Self { version: "bundled".into() }
|
||||
Self { version: "bundled".into(), tsserver_max_memory: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -266,12 +266,16 @@ pub struct DebuggerSettings {
|
||||
pub inline_values: bool,
|
||||
/// Data Views: list variables by name instead of the adapter's order.
|
||||
pub sort_values: bool,
|
||||
/// Data Views: evaluate watches while the program runs, where the debugger can (JavaScript).
|
||||
pub live_watches: bool,
|
||||
pub value_tooltip: bool,
|
||||
/// While suspended, selecting an expression shows its value.
|
||||
pub value_tooltip_on_selection: bool,
|
||||
pub value_tooltip_delay: u32,
|
||||
/// Stepping: do not step into library code (debugpy `justMyCode`, js-debug `skipFiles`).
|
||||
pub skip_library_code: bool,
|
||||
/// JavaScript: debugging a dev server (`vite`, `next dev`) also opens its page in a browser session.
|
||||
pub debug_server_page: bool,
|
||||
pub python: PythonDebuggerSettings,
|
||||
}
|
||||
|
||||
@@ -285,10 +289,12 @@ impl Default for DebuggerSettings {
|
||||
run_to_cursor_inlay: true,
|
||||
inline_values: true,
|
||||
sort_values: false,
|
||||
live_watches: false,
|
||||
value_tooltip: true,
|
||||
value_tooltip_on_selection: true,
|
||||
value_tooltip_delay: 700,
|
||||
skip_library_code: true,
|
||||
debug_server_page: false,
|
||||
python: PythonDebuggerSettings::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
use ide_core::process::executable_names;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_language::{DebuggerConfig, LanguageRegistry, ProgramPin};
|
||||
use serde_json::Value;
|
||||
use std::future::Future;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
@@ -212,6 +213,50 @@ pub fn tools_root() -> Option<PathBuf> {
|
||||
TOOLS_ROOT.read().clone()
|
||||
}
|
||||
|
||||
/// The browser js-debug launches for `ty`, or `None` when the launch starts no browser.
|
||||
fn launched_browser(ty: &str) -> Option<&'static str> {
|
||||
match ty {
|
||||
"pwa-chrome" | "chrome" => Some("chrome"),
|
||||
"pwa-msedge" | "msedge" => Some("msedge"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gives a browser launch that names no `userDataDir` a profile of `project`'s own in the
|
||||
/// IDE's local data (`cache/browser-profiles`), so sign-ins and site data are still there in
|
||||
/// the next session. js-debug's own default is one temporary profile for every project.
|
||||
pub(crate) fn set_browser_profile(arguments: &mut Value, project: &Path) {
|
||||
if let Some(local_data) = tools_root().and_then(|tools| tools.parent().map(Path::to_owned)) {
|
||||
set_browser_profile_in(arguments, project, &local_data);
|
||||
}
|
||||
}
|
||||
|
||||
fn set_browser_profile_in(arguments: &mut Value, project: &Path, local_data: &Path) {
|
||||
let Some(obj) = arguments.as_object_mut() else { return };
|
||||
let browser = obj.get("type").and_then(Value::as_str).and_then(launched_browser);
|
||||
let launch = obj.get("request").and_then(Value::as_str) == Some("launch");
|
||||
let (Some(browser), true, false) = (browser, launch, obj.contains_key("userDataDir")) else { return };
|
||||
let dir = local_data.join("cache").join("browser-profiles").join(profile_name(project)).join(browser);
|
||||
obj.insert("userDataDir".into(), dir.to_string_lossy().into_owned().into());
|
||||
}
|
||||
|
||||
/// `<folder name>-<hash of the path>`: readable, and distinct for projects whose folders share a name.
|
||||
fn profile_name(project: &Path) -> String {
|
||||
// FNV-1a: the name has to stay the same across toolchains, which `DefaultHasher` doesn't promise.
|
||||
let hash = project
|
||||
.to_string_lossy()
|
||||
.bytes()
|
||||
.fold(0xcbf2_9ce4_8422_2325_u64, |h, b| (h ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3));
|
||||
let name: String = project
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{name}-{hash:016x}")
|
||||
}
|
||||
|
||||
/// Executable directories of managed installs, read at lookup time so tools installed
|
||||
/// after startup are found without a restart: `<tool>/bin`, `<tool>/venv/bin`, npm's `.bin`.
|
||||
fn managed_dirs(root: &Path) -> Vec<PathBuf> {
|
||||
@@ -1040,6 +1085,33 @@ mod tests {
|
||||
assert!(err.message.contains("runs inside the Java language server"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_launch_gets_a_profile_of_its_project() {
|
||||
let local = Path::new("/data/local");
|
||||
let profile = |args: serde_json::Value, project: &str| {
|
||||
let mut args = args;
|
||||
set_browser_profile_in(&mut args, Path::new(project), local);
|
||||
args.get("userDataDir").cloned()
|
||||
};
|
||||
let chrome = serde_json::json!({ "type": "pwa-chrome", "request": "launch" });
|
||||
let dir = profile(chrome.clone(), "/work/shop").unwrap();
|
||||
let dir = Path::new(dir.as_str().unwrap());
|
||||
assert!(dir.starts_with("/data/local/cache/browser-profiles"), "{dir:?}");
|
||||
assert!(dir.ends_with("chrome"), "{dir:?}");
|
||||
let name = dir.parent().unwrap().file_name().unwrap().to_string_lossy().into_owned();
|
||||
assert!(name.starts_with("shop-") && name.len() == "shop-".len() + 16, "{name}");
|
||||
// The same project again: the same profile. Another folder of that name, or Edge: another one.
|
||||
assert_eq!(profile(chrome.clone(), "/work/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
assert_ne!(profile(chrome.clone(), "/other/shop").unwrap(), dir.to_string_lossy().as_ref());
|
||||
let edge = profile(serde_json::json!({ "type": "pwa-msedge", "request": "launch" }), "/work/shop").unwrap();
|
||||
assert!(Path::new(edge.as_str().unwrap()).ends_with("msedge"), "{edge}");
|
||||
// A chosen profile (or the default one, `false`) is kept; Node and attach requests get none.
|
||||
let own = serde_json::json!({ "type": "pwa-chrome", "request": "launch", "userDataDir": false });
|
||||
assert_eq!(profile(own, "/work/shop"), Some(false.into()));
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-node", "request": "launch" }), "/work/shop"), None);
|
||||
assert_eq!(profile(serde_json::json!({ "type": "pwa-chrome", "request": "attach" }), "/work/shop"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn port_conflicts_are_recognised_for_retry() {
|
||||
let in_use = IdeError::unavailable(
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Requests of vscode-js-debug's own, beyond the Debug Adapter Protocol: what a page in a
|
||||
//! browser offers a debugger besides stepping (breakpoints on DOM events and requests).
|
||||
//! Each is a typed method; a js-debug build that lacks one answers with an error, which is
|
||||
//! reported as the feature being unavailable.
|
||||
|
||||
use crate::session::Session;
|
||||
use crate::{DebugManager, DebugSessionId, REQUEST_TIMEOUT};
|
||||
use ide_core::event::{JsInitiator, JsNetworkEvent, JsNetworkKind};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub(crate) const ADAPTER: &str = "js-debug";
|
||||
|
||||
/// Whether `session` debugs a page: the child js-debug starts for a browser launch or attach.
|
||||
/// The launch's own session only owns the browser.
|
||||
pub(crate) fn is_page(session: &Session) -> bool {
|
||||
session.adapter == ADAPTER
|
||||
&& session.parent.is_some()
|
||||
&& matches!(session.launch_type.get().map(String::as_str), Some("pwa-chrome" | "pwa-msedge"))
|
||||
}
|
||||
|
||||
/// Waits until the adapter answered the `launch` of `session`. js-debug sets a page's session
|
||||
/// up between `configurationDone` and that answer; a request of its own that arrives then is
|
||||
/// answered and has no effect.
|
||||
async fn launched(session: &Session) -> bool {
|
||||
let mut launched = session.launched.subscribe();
|
||||
let answered = tokio::time::timeout(REQUEST_TIMEOUT, launched.wait_for(|answered| *answered)).await;
|
||||
matches!(answered, Ok(Ok(_)))
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// One of js-debug's requests for pages, sent to the page `s` once its session has started.
|
||||
async fn js_page_request(&self, s: DebugSessionId, command: &str, arguments: Value) -> IdeResult<Value> {
|
||||
let session = self.session(s)?;
|
||||
if !is_page(&session) {
|
||||
return Err(IdeError::unavailable("this debug session is not a page in a browser"));
|
||||
}
|
||||
if !launched(&session).await {
|
||||
return Err(IdeError::unavailable("the page's debug session did not finish starting"));
|
||||
}
|
||||
session
|
||||
.conn
|
||||
.request_timeout(command, arguments, REQUEST_TIMEOUT)
|
||||
.await
|
||||
.map_err(|e| IdeError::unavailable(format!("the JavaScript debugger refused `{command}`: {e}")))
|
||||
}
|
||||
|
||||
/// Breakpoints on events (`listener:click`, `instrumentation:setTimeout`) and on requests
|
||||
/// whose URL contains one of `xhr`. The lists replace the page's previous ones.
|
||||
pub async fn js_set_custom_breakpoints(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
ids: Vec<String>,
|
||||
xhr: Vec<String>,
|
||||
) -> IdeResult<()> {
|
||||
self.js_page_request(s, "setCustomBreakpoints", json!({ "ids": ids, "xhr": xhr })).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The browser events js-debug is asked to pass on (`enableNetworking`'s `mirrorEvents`).
|
||||
pub(crate) const NETWORK_EVENTS: [&str; 4] =
|
||||
["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"];
|
||||
|
||||
/// Response bodies shown are cut at this many bytes.
|
||||
pub const RESPONSE_BODY_LIMIT: usize = 1_048_576;
|
||||
|
||||
/// The body of a response, as text or as the browser's base64 for binary content.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JsResponseBody {
|
||||
pub body: String,
|
||||
pub base64_encoded: bool,
|
||||
/// The body was longer than [`RESPONSE_BODY_LIMIT`] and is cut there.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
fn text(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn headers(v: &Value) -> BTreeMap<String, String> {
|
||||
v.get("headers")
|
||||
.and_then(Value::as_object)
|
||||
.map(|h| h.iter().filter_map(|(k, v)| Some((k.clone(), v.as_str()?.to_owned()))).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The frame a request is attributed to: the innermost one outside `node_modules`, looked
|
||||
/// for through the stacks that scheduled this one (an `await`, a timer), since a request sent
|
||||
/// through a library has the library innermost. A stack of library frames only gives its first.
|
||||
fn initiator(data: &Value) -> Option<JsInitiator> {
|
||||
let stack = data.pointer("/initiator/stack")?;
|
||||
let frames = |stack: &Value| stack.get("callFrames").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
let stacks = std::iter::successors(Some(stack), |stack| stack.get("parent")).take(32);
|
||||
let all: Vec<Value> = stacks.flat_map(frames).collect();
|
||||
let own = |frame: &&Value| {
|
||||
frame.get("url").and_then(Value::as_str).is_some_and(|url| !url.is_empty() && !url.contains("/node_modules/"))
|
||||
};
|
||||
let frame = all.iter().find(own).or(all.first())?;
|
||||
let number = |key: &str| frame.get(key).and_then(Value::as_u64).and_then(|n| u32::try_from(n).ok()).unwrap_or(0);
|
||||
Some(JsInitiator {
|
||||
function: text(frame, "functionName").unwrap_or_default(),
|
||||
url: text(frame, "url")?,
|
||||
line: number("lineNumber"),
|
||||
column: number("columnNumber"),
|
||||
})
|
||||
}
|
||||
|
||||
/// js-debug's `networkEvent` (`{event, data}`, `data` being the browser's own parameters of
|
||||
/// that event) as a [`JsNetworkEvent`]; `None` for events the Network view has no use for.
|
||||
pub(crate) fn network_event(body: &Value) -> Option<JsNetworkEvent> {
|
||||
let data = body.get("data")?;
|
||||
let kind = match body.get("event").and_then(Value::as_str)? {
|
||||
"requestWillBeSent" => JsNetworkKind::Request,
|
||||
"responseReceived" => JsNetworkKind::Response,
|
||||
"loadingFinished" => JsNetworkKind::Finished,
|
||||
"loadingFailed" => JsNetworkKind::Failed,
|
||||
_ => return None,
|
||||
};
|
||||
let part = match kind {
|
||||
JsNetworkKind::Request => data.get("request"),
|
||||
JsNetworkKind::Response => data.get("response"),
|
||||
_ => None,
|
||||
};
|
||||
let part = part.unwrap_or(&Value::Null);
|
||||
Some(JsNetworkEvent {
|
||||
kind,
|
||||
request_id: text(data, "requestId")?,
|
||||
// Seconds with a fraction.
|
||||
timestamp_ms: (data.get("timestamp").and_then(Value::as_f64).unwrap_or(0.0).max(0.0) * 1000.0).round() as u64,
|
||||
url: text(part, "url"),
|
||||
method: text(part, "method"),
|
||||
resource_type: text(data, "type"),
|
||||
status: part.get("status").and_then(Value::as_u64).and_then(|s| u16::try_from(s).ok()),
|
||||
mime_type: text(part, "mimeType"),
|
||||
headers: headers(part),
|
||||
encoded_length: data.get("encodedDataLength").and_then(Value::as_f64).map(|n| n.max(0.0) as u64),
|
||||
error_text: text(data, "errorText"),
|
||||
initiator: if kind == JsNetworkKind::Request { initiator(data) } else { None },
|
||||
})
|
||||
}
|
||||
|
||||
/// `networkCall getResponseBody`'s answer, cut at `limit` bytes on a character boundary.
|
||||
fn response_body(answer: &Value, limit: usize) -> JsResponseBody {
|
||||
let mut body = text(answer, "body").unwrap_or_default();
|
||||
let truncated = body.len() > limit;
|
||||
if truncated {
|
||||
let mut end = limit;
|
||||
while !body.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
body.truncate(end);
|
||||
}
|
||||
JsResponseBody {
|
||||
body,
|
||||
base64_encoded: answer.get("base64Encoded").and_then(Value::as_bool).unwrap_or(false),
|
||||
truncated,
|
||||
}
|
||||
}
|
||||
|
||||
/// The arguments of `enableNetworking`, which has a page report its requests
|
||||
/// ([`ide_core::event::DebugEvent::JsNetwork`]). A page session sends it as it starts.
|
||||
pub(crate) fn networking_arguments() -> Value {
|
||||
json!({ "mirrorEvents": NETWORK_EVENTS })
|
||||
}
|
||||
|
||||
impl DebugManager {
|
||||
/// The body of the response to `request_id`, while the browser still has it.
|
||||
pub async fn js_response_body(&self, s: DebugSessionId, request_id: String) -> IdeResult<JsResponseBody> {
|
||||
let call = json!({ "method": "getResponseBody", "params": { "requestId": request_id } });
|
||||
let answer = self.js_page_request(s, "networkCall", call).await?;
|
||||
Ok(response_body(&answer, RESPONSE_BODY_LIMIT))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn event(kind: &str, data: Value) -> Option<ide_core::event::JsNetworkEvent> {
|
||||
network_event(&json!({ "event": kind, "data": data }))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_carries_what_was_asked_and_who_asked() {
|
||||
let e = event(
|
||||
"requestWillBeSent",
|
||||
json!({
|
||||
"requestId": "76695.31", "timestamp": 611807.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://127.0.0.1:5195/src/main.ts", "headers": { "Accept": "*/*", "X-N": 1 } },
|
||||
"initiator": { "type": "script", "stack": { "callFrames": [
|
||||
{ "functionName": "load", "url": "http://127.0.0.1:5195/src/App.vue", "lineNumber": 9, "columnNumber": 2 }
|
||||
] } }
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(e.kind, JsNetworkKind::Request);
|
||||
assert_eq!((e.request_id.as_str(), e.timestamp_ms), ("76695.31", 611_807_500));
|
||||
assert_eq!(e.url.as_deref(), Some("http://127.0.0.1:5195/src/main.ts"));
|
||||
assert_eq!((e.method.as_deref(), e.resource_type.as_deref()), (Some("GET"), Some("Fetch")));
|
||||
// Only text headers are kept.
|
||||
assert_eq!(e.headers.len(), 1);
|
||||
assert_eq!(e.headers["Accept"], "*/*");
|
||||
let from = e.initiator.unwrap();
|
||||
assert_eq!((from.function.as_str(), from.line, from.column), ("load", 9, 2));
|
||||
assert_eq!(from.url, "http://127.0.0.1:5195/src/App.vue");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_request_made_through_a_library_is_asked_by_the_code_that_called_the_library() {
|
||||
let frame = |function: &str, url: &str| json!({ "functionName": function, "url": url, "lineNumber": 1, "columnNumber": 0 });
|
||||
let lib = "http://localhost:5173/node_modules/.vite/deps/http-client.js";
|
||||
let asked_by = |stack: Value| {
|
||||
let data = json!({ "requestId": "1", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/api" },
|
||||
"initiator": { "type": "script", "stack": stack } });
|
||||
event("requestWillBeSent", data).unwrap().initiator.unwrap().function
|
||||
};
|
||||
// The innermost frame is the library's; the project's caller is further out.
|
||||
let direct = json!({ "callFrames": [frame("dispatch", lib), frame("request", lib), frame("loadItems", "http://localhost:5173/src/api.ts")] });
|
||||
assert_eq!(asked_by(direct), "loadItems");
|
||||
// Across an `await`, the caller is in the stack that scheduled this one.
|
||||
let scheduled = json!({ "callFrames": [frame("dispatch", lib)],
|
||||
"parent": { "callFrames": [frame("then", lib)], "parent": { "callFrames": [frame("onMounted", "http://localhost:5173/src/App.vue")] } } });
|
||||
assert_eq!(asked_by(scheduled), "onMounted");
|
||||
// A library on its own: the frame that sent the request.
|
||||
assert_eq!(asked_by(json!({ "callFrames": [frame("dispatch", lib), frame("poll", lib)] })), "dispatch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_a_finish_and_a_failure_carry_their_outcome() {
|
||||
let response = event(
|
||||
"responseReceived",
|
||||
json!({ "requestId": "7", "timestamp": 2.0, "type": "Script", "response": {
|
||||
"url": "http://localhost/a.js", "status": 200, "mimeType": "text/javascript",
|
||||
"headers": { "Content-Type": "text/javascript" }
|
||||
} }),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response.kind, JsNetworkKind::Response);
|
||||
assert_eq!((response.status, response.mime_type.as_deref()), (Some(200), Some("text/javascript")));
|
||||
assert_eq!(response.headers["Content-Type"], "text/javascript");
|
||||
|
||||
let finished =
|
||||
event("loadingFinished", json!({ "requestId": "7", "timestamp": 2.5, "encodedDataLength": 1234 })).unwrap();
|
||||
assert_eq!((finished.kind, finished.encoded_length), (JsNetworkKind::Finished, Some(1234)));
|
||||
|
||||
let failed =
|
||||
event("loadingFailed", json!({ "requestId": "8", "timestamp": 3.0, "errorText": "net::ERR_FAILED" }))
|
||||
.unwrap();
|
||||
assert_eq!((failed.kind, failed.error_text.as_deref()), (JsNetworkKind::Failed, Some("net::ERR_FAILED")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_that_name_no_request_or_are_not_mirrored_are_dropped() {
|
||||
assert!(event("requestWillBeSent", json!({ "timestamp": 1.0 })).is_none());
|
||||
assert!(event("dataReceived", json!({ "requestId": "7", "timestamp": 1.0 })).is_none());
|
||||
assert!(network_event(&json!({ "data": { "requestId": "7" } })).is_none());
|
||||
// A request made by the page's parser has no script behind it.
|
||||
let parser = event(
|
||||
"requestWillBeSent",
|
||||
json!({ "requestId": "9", "timestamp": 1.0, "request": { "method": "GET", "url": "http://localhost/" },
|
||||
"initiator": { "type": "parser" } }),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(parser.initiator.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_response_body_is_cut_at_the_limit_on_a_character() {
|
||||
let whole = response_body(&json!({ "body": "héllo", "base64Encoded": false }), 100);
|
||||
assert_eq!((whole.body.as_str(), whole.base64_encoded, whole.truncated), ("héllo", false, false));
|
||||
// `é` is two bytes: a cut inside it backs off to the character before.
|
||||
let cut = response_body(&json!({ "body": "héllo" }), 2);
|
||||
assert_eq!((cut.body.as_str(), cut.truncated), ("h", true));
|
||||
let binary = response_body(&json!({ "body": "AAEC", "base64Encoded": true }), 100);
|
||||
assert!(binary.base64_encoded && !binary.truncated);
|
||||
assert_eq!(response_body(&json!({}), 100).body, "");
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ pub mod attach;
|
||||
mod cargo;
|
||||
pub mod connection;
|
||||
pub mod jdwp;
|
||||
mod js_debug;
|
||||
mod jvm_memory;
|
||||
pub mod kotlin;
|
||||
pub mod kotlin_lsp;
|
||||
@@ -23,6 +24,7 @@ use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
pub use js_debug::JsResponseBody;
|
||||
pub use jvm_memory::*;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -136,6 +138,11 @@ pub struct DebugCapabilities {
|
||||
pub jvm_instances: bool,
|
||||
/// The session can be asked for a heap dump ([`DebugManager::jvm_heap_dump`]).
|
||||
pub jvm_heap: bool,
|
||||
/// The session debugs a page in a browser: js-debug's requests for pages apply
|
||||
/// ([`DebugManager::js_set_custom_breakpoints`]).
|
||||
pub js_page: bool,
|
||||
/// The adapter evaluates expressions while the program runs, without a stack frame.
|
||||
pub live_evaluate: bool,
|
||||
}
|
||||
|
||||
/// Result of [`DebugManager::redefine_classes`].
|
||||
@@ -393,6 +400,16 @@ fn no_session(s: DebugSessionId) -> IdeError {
|
||||
IdeError::not_found(format!("no debug session {}", s.0))
|
||||
}
|
||||
|
||||
/// The name of a child session: the adapter's, or the parent's when it gives none. js-debug
|
||||
/// names a browser page's session after the page the browser starts on, before the URL
|
||||
/// loads (`about:blank`, or nothing in a new profile), which says nothing about what is debugged.
|
||||
fn child_name(configuration: &Value, parent: &str) -> String {
|
||||
match configuration.get("name").and_then(Value::as_str) {
|
||||
Some(name) if !name.trim().is_empty() && name != "about:blank" => name.to_owned(),
|
||||
_ => parent.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the `launch`/`attach` arguments: our fields first, then `extra_json` merged on top.
|
||||
pub fn launch_arguments(config: &LaunchConfig) -> IdeResult<Value> {
|
||||
// VS Code puts `type`, `request` and `name` in the arguments; js-debug picks launch
|
||||
@@ -580,6 +597,8 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
jvm_memory: false,
|
||||
jvm_instances: false,
|
||||
jvm_heap: false,
|
||||
js_page: false,
|
||||
live_evaluate: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -589,6 +608,8 @@ pub(crate) fn session_capabilities(session: &Session) -> DebugCapabilities {
|
||||
caps.jvm_memory = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.jvm_instances = session.jvm_inspector.lock().is_some();
|
||||
caps.jvm_heap = JVM_MEMORY_ADAPTERS.contains(&session.adapter.as_str());
|
||||
caps.js_page = js_debug::is_page(session);
|
||||
caps.live_evaluate = session.adapter == js_debug::ADAPTER;
|
||||
caps
|
||||
}
|
||||
|
||||
@@ -744,6 +765,9 @@ impl DebugManager {
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
|
||||
let dbg = adapter::find_config(&self.languages, &config.adapter)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("unknown debug adapter `{}`", config.adapter)))?;
|
||||
if config.adapter == "js-debug" {
|
||||
adapter::set_browser_profile(&mut arguments, &cwd);
|
||||
}
|
||||
let mut spec = adapter::resolve(&dbg, cwd.clone());
|
||||
if config.adapter == kotlin::ADAPTER
|
||||
&& let Some(home) = arguments
|
||||
@@ -872,8 +896,7 @@ impl DebugManager {
|
||||
request: String,
|
||||
configuration: Value,
|
||||
) -> IdeResult<()> {
|
||||
let name =
|
||||
configuration.get("name").and_then(Value::as_str).map(str::to_owned).unwrap_or_else(|| parent.name.clone());
|
||||
let name = child_name(&configuration, &parent.name);
|
||||
let mut configuration = configuration;
|
||||
if let Some(obj) = configuration.as_object_mut() {
|
||||
obj.entry("request").or_insert_with(|| request.clone().into());
|
||||
@@ -921,6 +944,9 @@ impl DebugManager {
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(launch_type) = arguments.get("type").and_then(Value::as_str) {
|
||||
let _ = session.launch_type.set(launch_type.to_owned());
|
||||
}
|
||||
// rdbg execs the debuggee in its own process and sends no DAP process event.
|
||||
if let Some(pid) = rdbg_pid {
|
||||
session.debuggee_pid.store(i64::from(pid), Ordering::Release);
|
||||
@@ -1155,6 +1181,22 @@ impl DebugManager {
|
||||
reply.await.unwrap_or(Err(DapError::Closed))?;
|
||||
}
|
||||
}
|
||||
s.launched.send_replace(true);
|
||||
// A page reports its requests from here on. This is the earliest js-debug takes the
|
||||
// request (before its answer to `launch` the page has no adapter and the request is
|
||||
// never answered), and the page is already loading: its document and first scripts
|
||||
// are not in the list.
|
||||
if js_debug::is_page(s) {
|
||||
let answer = s.conn.send_request("enableNetworking", js_debug::networking_arguments())?;
|
||||
let session = s.clone();
|
||||
// Not waited for here: the page runs on. A refusal is told to the Network view,
|
||||
// which would otherwise wait for requests that never come.
|
||||
tokio::spawn(async move {
|
||||
if matches!(answer.await, Ok(Err(_))) {
|
||||
session.emit(ide_core::event::DebugEvent::JsNetworkUnavailable);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(link) = s.jdwp_link.lock().as_ref() {
|
||||
link.release();
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ pub(crate) struct Session {
|
||||
pub job: Mutex<Option<ide_core::process::Job>>,
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
/// Set once the adapter answered `launch` / `attach`, after the configuration.
|
||||
pub launched: watch::Sender<bool>,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
@@ -89,6 +91,9 @@ pub(crate) struct Session {
|
||||
progress: Mutex<HashMap<String, TaskId>>,
|
||||
/// Relay between a Kotlin adapter and its JVM. Dropping it closes both sockets.
|
||||
pub jdwp_link: parking_lot::Mutex<Option<ide_jdwp::JdwpLink>>,
|
||||
/// The `type` of the launch or attach request (js-debug's `pwa-node`, `pwa-chrome`); a child
|
||||
/// session's comes from the configuration the adapter started it with.
|
||||
pub launch_type: std::sync::OnceLock<String>,
|
||||
pub jvm_inspector: parking_lot::Mutex<Option<Arc<dyn ide_jvm_inspect::JvmInspector>>>,
|
||||
/// One inspector request per session at a time.
|
||||
pub inspect_lock: tokio::sync::Mutex<()>,
|
||||
@@ -230,6 +235,7 @@ impl Session {
|
||||
#[cfg(windows)]
|
||||
job: Mutex::new(job),
|
||||
configured: AtomicBool::new(false),
|
||||
launched: watch::channel(false).0,
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
@@ -248,6 +254,7 @@ impl Session {
|
||||
resolved_paths: Mutex::new(HashMap::new()),
|
||||
progress: Mutex::new(HashMap::new()),
|
||||
jdwp_link: parking_lot::Mutex::new(None),
|
||||
launch_type: std::sync::OnceLock::new(),
|
||||
jvm_inspector: parking_lot::Mutex::new(None),
|
||||
inspect_lock: tokio::sync::Mutex::default(),
|
||||
ended: ide_core::CancellationToken::new(),
|
||||
@@ -832,6 +839,12 @@ async fn on_event(mgr: &Arc<DebugManager>, s: &Arc<Session>, event: &str, mut bo
|
||||
.map(|a| a.iter().filter_map(Value::as_str).map(str::to_owned).collect())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
// js-debug, after `enableNetworking`.
|
||||
"networkEvent" => {
|
||||
if let Some(event) = crate::js_debug::network_event(&body) {
|
||||
s.emit(DebugEvent::JsNetwork { event });
|
||||
}
|
||||
}
|
||||
_ => tracing::trace!("dap: ignored event {event}"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +146,10 @@ struct Script {
|
||||
delay_initialize: Option<Duration>,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Answer a child session's `launch` this long after its `configurationDone`, reading
|
||||
/// requests meanwhile. Like js-debug, one of `accept` that arrives in between is answered
|
||||
/// and has no effect: the log gets a `dropped` entry for it.
|
||||
child_launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
@@ -154,8 +158,15 @@ struct Script {
|
||||
/// `launch`, and a `configurationDone` sent while it runs is refused with `notStopped`.
|
||||
gdb15_starts_at_launch: bool,
|
||||
launch_after_configuration: bool,
|
||||
/// Requests of the adapter's own (js-debug's `setCustomBreakpoints`…) answered with an empty body.
|
||||
accept: &'static [&'static str],
|
||||
/// Requests of the adapter's own answered with a body and followed by events.
|
||||
custom: Option<fn(&Value) -> Option<CustomReply>>,
|
||||
}
|
||||
|
||||
/// The answer to a request of the adapter's own: the response body, then events (name, body).
|
||||
type CustomReply = (Value, Vec<(&'static str, Value)>);
|
||||
|
||||
impl Default for Script {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
@@ -181,11 +192,14 @@ impl Default for Script {
|
||||
delay_initialize: None,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
child_launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
gdb15_starts_at_launch: false,
|
||||
launch_after_configuration: false,
|
||||
accept: &[],
|
||||
custom: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,10 +232,15 @@ impl AdapterLauncher for FakeLauncher {
|
||||
}
|
||||
|
||||
async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream: S, log: Log, script: Script) {
|
||||
let (r, mut w) = tokio::io::split(stream);
|
||||
let (r, w) = tokio::io::split(stream);
|
||||
// Shared with the task that answers a child's `launch` late (`child_launch_delay`).
|
||||
let w = Arc::new(tokio::sync::Mutex::new(w));
|
||||
let mut r = BufReader::new(r);
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut child = false;
|
||||
// Between `configurationDone` and the answer to `launch`.
|
||||
let starting = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
let mut stopped = false;
|
||||
let mut running = false;
|
||||
@@ -230,7 +249,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut v: Value = $v;
|
||||
seq += 1;
|
||||
v["seq"] = seq.into();
|
||||
framing::write_message(&mut w, &serde_json::to_vec(&v).unwrap()).await.unwrap();
|
||||
framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&v).unwrap()).await.unwrap();
|
||||
}};
|
||||
}
|
||||
macro_rules! ok {
|
||||
@@ -292,6 +311,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
continue;
|
||||
}
|
||||
pending_launch = msg["seq"].as_i64();
|
||||
child = msg["arguments"]["isChild"] == true;
|
||||
if let Some(args) = &script.run_in_terminal {
|
||||
send!(
|
||||
json!({"type": "request", "command": "runInTerminal", "arguments": {"kind": "integrated", "args": args, "cwd": std::env::temp_dir()}})
|
||||
@@ -299,7 +319,8 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
if script.start_debugging && msg["arguments"]["isChild"] != true {
|
||||
send!(
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
|
||||
// Like js-debug, the child's configuration repeats the launch's `type`.
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true, "type": msg["arguments"]["type"]}}})
|
||||
);
|
||||
}
|
||||
if script.pending_startup != Some(false) {
|
||||
@@ -378,7 +399,21 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
tokio::time::sleep(script.launch_delay).await;
|
||||
if let Some(l) = pending_launch.take() {
|
||||
send!(json!({"type": "response", "request_seq": l, "command": "launch", "success": true}));
|
||||
let answer = json!({"type": "response", "request_seq": l, "command": "launch", "success": true});
|
||||
if child && !script.child_launch_delay.is_zero() {
|
||||
starting.store(true, Ordering::Release);
|
||||
let (w, starting, delay) = (w.clone(), starting.clone(), script.child_launch_delay);
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(delay).await;
|
||||
let mut answer = answer;
|
||||
answer["seq"] = 999_999.into();
|
||||
let _ = framing::write_message(&mut *w.lock().await, &serde_json::to_vec(&answer).unwrap())
|
||||
.await;
|
||||
starting.store(false, Ordering::Release);
|
||||
});
|
||||
} else {
|
||||
send!(answer);
|
||||
}
|
||||
}
|
||||
if let Some(pid) = script.debuggee_pid {
|
||||
let event = if script.java_process_id {
|
||||
@@ -474,9 +509,25 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
}
|
||||
"disconnect" => {
|
||||
ok!(msg, Value::Null);
|
||||
let _ = w.shutdown().await;
|
||||
let _ = w.lock().await.shutdown().await;
|
||||
break;
|
||||
}
|
||||
other if script.accept.contains(&other) => {
|
||||
if starting.load(Ordering::Acquire) {
|
||||
log.lock().push(json!({"type": "dropped", "command": other}));
|
||||
}
|
||||
ok!(msg, json!({}))
|
||||
}
|
||||
other if script.custom.and_then(|custom| custom(&msg)).is_some() => {
|
||||
if starting.load(Ordering::Acquire) {
|
||||
log.lock().push(json!({"type": "dropped", "command": other}));
|
||||
}
|
||||
let (body, events) = script.custom.and_then(|custom| custom(&msg)).unwrap();
|
||||
ok!(msg, body);
|
||||
for (event, body) in events {
|
||||
send!(json!({"type": "event", "event": event, "body": body}));
|
||||
}
|
||||
}
|
||||
other => send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": other, "success": false, "message": "unsupported"})
|
||||
),
|
||||
@@ -905,6 +956,162 @@ async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
fn js_config(launch_type: &str) -> LaunchConfig {
|
||||
LaunchConfig {
|
||||
adapter: "js-debug".into(),
|
||||
program: None,
|
||||
args: Vec::new(),
|
||||
extra_json: Some(format!(r#"{{"type": "{launch_type}"}}"#)),
|
||||
..config()
|
||||
}
|
||||
}
|
||||
|
||||
/// The session the adapter started under `parent`.
|
||||
async fn child_of(h: &Harness, parent: DebugSessionId) -> DebugSessionId {
|
||||
for _ in 0..200 {
|
||||
if let Some(child) = h.manager.sessions(Some("main")).into_iter().find(|s| s.parent == Some(parent)) {
|
||||
return child.id;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("no child session of {parent:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_browser_page_session_has_the_page_capability() {
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = h.manager.capabilities(child_of(&h, browser).await).unwrap();
|
||||
assert!(page.js_page && page.live_evaluate);
|
||||
assert!(!h.manager.capabilities(browser).unwrap().js_page, "the launch itself is not a page");
|
||||
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
let process = h.manager.capabilities(child_of(&h, node).await).unwrap();
|
||||
assert!(!process.js_page && process.live_evaluate);
|
||||
|
||||
let python = h.start(config()).await.unwrap();
|
||||
let script = h.manager.capabilities(child_of(&h, python).await).unwrap();
|
||||
assert!(!script.js_page && !script.live_evaluate);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_breakpoints_are_sent_to_a_page_and_refused_elsewhere() {
|
||||
let h = harness(Script { start_debugging: true, accept: &["setCustomBreakpoints"], ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], vec!["api/".into()]).await.unwrap();
|
||||
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": ["api/"]})]);
|
||||
|
||||
let not_a_page = h.manager.js_set_custom_breakpoints(browser, Vec::new(), Vec::new()).await.unwrap_err();
|
||||
assert_eq!(not_a_page.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
|
||||
// A js-debug build without the request.
|
||||
let old = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = old.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&old, browser).await;
|
||||
let refused = old.manager.js_set_custom_breakpoints(page, Vec::new(), Vec::new()).await.unwrap_err();
|
||||
assert_eq!(refused.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
/// js-debug answers a request of its own that reaches a page after `configurationDone` and
|
||||
/// before the page's `launch` is answered, and then does nothing with it.
|
||||
#[tokio::test]
|
||||
async fn a_page_gets_its_requests_once_its_session_has_started() {
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
accept: &["setCustomBreakpoints"],
|
||||
child_launch_delay: Duration::from_millis(150),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
// Past the page's `configurationDone`, with its `launch` still unanswered.
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
h.manager.js_set_custom_breakpoints(page, vec!["listener:click".into()], Vec::new()).await.unwrap();
|
||||
assert_eq!(requests(&h, "setCustomBreakpoints"), [json!({"ids": ["listener:click"], "xhr": []})]);
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "the request reached the page before it took requests");
|
||||
}
|
||||
|
||||
/// js-debug's network requests: `enableNetworking` is followed by one mirrored browser event.
|
||||
fn network_adapter(msg: &Value) -> Option<CustomReply> {
|
||||
match msg["command"].as_str()? {
|
||||
"enableNetworking" => Some((
|
||||
json!({}),
|
||||
vec![(
|
||||
"networkEvent",
|
||||
json!({ "event": "requestWillBeSent", "data": {
|
||||
"requestId": "7", "timestamp": 1.5, "type": "Fetch",
|
||||
"request": { "method": "GET", "url": "http://localhost:5173/api/items", "headers": {} }
|
||||
} }),
|
||||
)],
|
||||
)),
|
||||
"networkCall" => Some((json!({ "body": "é".repeat(600_000), "base64Encoded": false }), Vec::new())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_reports_its_requests_and_their_bodies() {
|
||||
let h = harness(Script {
|
||||
start_debugging: true,
|
||||
custom: Some(network_adapter),
|
||||
child_launch_delay: Duration::from_millis(100),
|
||||
..Script::default()
|
||||
});
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
let page = child_of(&h, browser).await;
|
||||
|
||||
// Recording is asked for by the session itself, as soon as js-debug takes the request:
|
||||
// once it has answered the page's `launch` (before that, the request is never answered).
|
||||
let DebugEvent::JsNetwork { event } = h.wait_event(|e| matches!(e, DebugEvent::JsNetwork { .. })).await else {
|
||||
unreachable!()
|
||||
};
|
||||
assert_eq!(
|
||||
requests(&h, "enableNetworking"),
|
||||
[json!({ "mirrorEvents": ["requestWillBeSent", "responseReceived", "loadingFinished", "loadingFailed"] })]
|
||||
);
|
||||
let dropped = h.log.lock().iter().filter(|m| m["type"] == "dropped").count();
|
||||
assert_eq!(dropped, 0, "recording was asked for before the page took requests");
|
||||
assert_eq!((event.request_id.as_str(), event.timestamp_ms), ("7", 1500));
|
||||
assert_eq!(event.url.as_deref(), Some("http://localhost:5173/api/items"));
|
||||
|
||||
let body = h.manager.js_response_body(page, "7".into()).await.unwrap();
|
||||
assert_eq!(requests(&h, "networkCall"), [json!({ "method": "getResponseBody", "params": { "requestId": "7" } })]);
|
||||
// 1.2 MB of two-byte characters: cut at the limit, on a character.
|
||||
assert!(body.truncated && !body.base64_encoded);
|
||||
assert_eq!(body.body.len(), 1_048_576);
|
||||
assert!(body.body.chars().all(|c| c == 'é'));
|
||||
|
||||
assert_eq!(
|
||||
h.manager.js_response_body(browser, "7".into()).await.unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
|
||||
// A Node.js program is not a page: nothing is asked of it.
|
||||
let node = h.start(js_config("pwa-node")).await.unwrap();
|
||||
child_of(&h, node).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert_eq!(requests(&h, "enableNetworking").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_page_whose_requests_the_adapter_does_not_report_says_so() {
|
||||
// A js-debug build without `enableNetworking`.
|
||||
let h = harness(Script { start_debugging: true, ..Script::default() });
|
||||
let browser = h.start(js_config("pwa-chrome")).await.unwrap();
|
||||
child_of(&h, browser).await;
|
||||
h.wait_event(|e| matches!(e, DebugEvent::JsNetworkUnavailable)).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_child_session_is_named_by_the_adapter_unless_it_is_a_blank_page() {
|
||||
assert_eq!(child_name(&json!({ "name": "server.js [4120]" }), "Server"), "server.js [4120]");
|
||||
assert_eq!(child_name(&json!({ "name": "about:blank" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({ "name": "" }), "Shop in Chrome"), "Shop in Chrome");
|
||||
assert_eq!(child_name(&json!({}), "Shop in Chrome"), "Shop in Chrome");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
|
||||
@@ -324,6 +324,17 @@ fn group_foreign_keys(rows: Vec<Vec<Option<String>>>, tables: &[CompletionTable]
|
||||
/// Connection target: a data source id (its configured database), or
|
||||
/// `<id>:<database>` for another database of the same server. Data source ids
|
||||
/// never contain `:`.
|
||||
/// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
fn tunneled(driver: DbDriver) -> bool {
|
||||
!matches!(driver, DbDriver::Sqlite | DbDriver::Snowflake)
|
||||
}
|
||||
|
||||
/// The database host and port the SSH host forwards to.
|
||||
fn tunnel_target(source: &DataSource) -> (&str, u16) {
|
||||
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
(if source.host.trim().is_empty() { "localhost" } else { source.host.trim() }, port)
|
||||
}
|
||||
|
||||
pub fn split_target(target: &str) -> (&str, Option<&str>) {
|
||||
match target.split_once(':') {
|
||||
Some((id, db)) if !db.is_empty() => (id, Some(db)),
|
||||
@@ -655,9 +666,7 @@ impl DbManager {
|
||||
ssh_secret: Option<String>,
|
||||
shared: bool,
|
||||
) -> IdeResult<(DataSource, Option<Arc<Tunnel>>)> {
|
||||
// Snowflake is HTTPS to a named host: a tunnel's local end cannot pass its certificate check.
|
||||
let tunneled = !matches!(source.driver, DbDriver::Sqlite | DbDriver::Snowflake);
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled) else {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|s| s.enabled && tunneled(source.driver)) else {
|
||||
return Ok((source.clone(), None));
|
||||
};
|
||||
let local = |t: &Tunnel| DataSource { host: "127.0.0.1".into(), port: Some(t.port()), ..source.clone() };
|
||||
@@ -668,8 +677,25 @@ impl DbManager {
|
||||
return Ok((local(&t), Some(t)));
|
||||
}
|
||||
}
|
||||
let session = self.ssh_session(source, ssh, ssh_secret).await?;
|
||||
let (target_host, target_port) = tunnel_target(source);
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
|
||||
/// The SSH session the tunnel of `source` goes through: its shared SSH configuration's, or one
|
||||
/// opened with the settings typed in the data source.
|
||||
async fn ssh_session(
|
||||
&self,
|
||||
source: &DataSource,
|
||||
ssh: &DbSsh,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<Arc<ide_ssh::Session>> {
|
||||
let config = ssh.config.trim();
|
||||
let session = if config.is_empty() {
|
||||
Ok(if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
@@ -688,14 +714,22 @@ impl DbManager {
|
||||
self.ssh.set_secret(config, Some(s), save);
|
||||
}
|
||||
self.ssh.session(config).await?
|
||||
};
|
||||
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
})
|
||||
}
|
||||
|
||||
/// Data Source › SSH/SSL › Test SSH Tunnel: logs in to the SSH host and checks that it reaches
|
||||
/// the database host and port, without logging in to the database.
|
||||
pub async fn test_tunnel(&self, source: &DataSource, ssh_secret: Option<String>) -> IdeResult<String> {
|
||||
let source = &source.resolved()?;
|
||||
let ssh = source
|
||||
.ssh
|
||||
.as_ref()
|
||||
.filter(|s| s.enabled && tunneled(source.driver))
|
||||
.ok_or_else(|| IdeError::invalid("This data source does not use an SSH tunnel"))?;
|
||||
let session = self.ssh_session(source, ssh, ssh_secret).await?;
|
||||
let (host, port) = tunnel_target(source);
|
||||
drop(Tunnel::open(session.clone(), host, port).await?);
|
||||
Ok(format!("{} reaches {host}:{port}", session.label()))
|
||||
}
|
||||
|
||||
/// Connects with `source` as given (not necessarily saved): Test Connection.
|
||||
|
||||
@@ -117,6 +117,14 @@ pub(crate) fn oracle_err(e: &oracledb::Error) -> IdeError {
|
||||
// config first. Treat it as a logon failure, so the UI prompts for one.
|
||||
let kind = match e.kind() {
|
||||
oracledb::ErrorKind::NoCredentials => ErrorKind::PermissionDenied,
|
||||
// The thin driver speaks the protocol of Oracle Database 12c and later only.
|
||||
oracledb::ErrorKind::ServerVersionNotSupported => {
|
||||
return IdeError::new(
|
||||
ErrorKind::Unavailable,
|
||||
"This Oracle Database is older than 12c. OpenIDEA connects to Oracle Database 12c and later; \
|
||||
11g and earlier are not supported.",
|
||||
);
|
||||
}
|
||||
_ => ErrorKind::InvalidInput,
|
||||
};
|
||||
return IdeError::new(kind, e.to_string());
|
||||
@@ -571,6 +579,33 @@ mod tests {
|
||||
assert_eq!(connect_string_with(&s("PROD=MYDB"), false).unwrap(), "PROD=MYDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_older_than_12c_is_named() {
|
||||
use std::io::{Read, Write};
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut socket, _) = listener.accept().unwrap();
|
||||
let mut connect = [0u8; 1024];
|
||||
let _ = socket.read(&mut connect);
|
||||
// A TNS ACCEPT packet with protocol version 314, as Oracle Database 11g answers.
|
||||
let mut accept = vec![0u8; 32];
|
||||
accept[1] = 32;
|
||||
accept[4] = 2;
|
||||
accept[8..10].copy_from_slice(&314u16.to_be_bytes());
|
||||
let _ = socket.write_all(&accept);
|
||||
});
|
||||
let config = oracledb::Config::default()
|
||||
.set_credentials("scott", "tiger")
|
||||
.set_connect_string(&format!("//127.0.0.1:{port}/ORCL"))
|
||||
.unwrap()
|
||||
.set_transport_connect_timeout(Some(std::time::Duration::from_secs(5)));
|
||||
let err = oracle_err(&oracledb::connect(config).err().expect("an 11g server is refused"));
|
||||
assert_eq!(err.kind, ErrorKind::Unavailable, "{err:?}");
|
||||
assert!(err.message.contains("older than 12c"), "{}", err.message);
|
||||
server.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tns_alias_resolves_when_tnsnames_exists() {
|
||||
let alias = DataSource {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Data Source › SSH/SSL › Test SSH Tunnel against an in-process SSH server: the SSH login and
|
||||
//! the SSH host reaching the database host and port, with no database login.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_db::{DataSource, DbDriver, DbManager, DbSsh, DbSshAuth};
|
||||
use ide_ssh::{SshFiles, SshManager};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
db: DbManager,
|
||||
ssh_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh_port = ide_ssh::testing::sshd(root.join("server"), Arc::new(|cmd| panic!("unexpected exec: {cmd}"))).await;
|
||||
let ssh = Arc::new(SshManager::with_files(
|
||||
root.join("ssh-secrets.json"),
|
||||
SshFiles { known_hosts: root.join("known_hosts"), config: None },
|
||||
));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
Env { _tmp: tmp, db, ssh_port }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
/// A PostgreSQL source on `127.0.0.1:<db_port>` through the test server as `dev`.
|
||||
fn source(&self, db_port: u16) -> DataSource {
|
||||
DataSource {
|
||||
id: "pg".into(),
|
||||
name: "pg".into(),
|
||||
driver: DbDriver::Postgres,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(db_port),
|
||||
user: "app".into(),
|
||||
ssh: Some(DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth: DbSshAuth::Password,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A port something listens on (and never speaks a database protocol).
|
||||
async fn listening() -> (TcpListener, u16) {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
(listener, port)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_tunnel_is_tested_without_a_database_login() {
|
||||
let env = env().await;
|
||||
let (_db, db_port) = listening().await;
|
||||
let source = env.source(db_port);
|
||||
|
||||
// First contact: the host key has to be trusted, as with Test Connection.
|
||||
let unknown = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(unknown.message.starts_with("Unknown SSH host key"), "{unknown:?}");
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let ok = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap();
|
||||
assert_eq!(ok, format!("[email protected]:{} reaches 127.0.0.1:{db_port}", env.ssh_port));
|
||||
|
||||
let wrong = env.db.test_tunnel(&source, Some("nope".into())).await.unwrap_err();
|
||||
assert!(wrong.message.contains("SSH authentication failed"), "{}", wrong.message);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_database_port_the_ssh_host_cannot_reach_is_named() {
|
||||
let env = env().await;
|
||||
let (listener, closed) = listening().await;
|
||||
drop(listener);
|
||||
let source = env.source(closed);
|
||||
let _ = env.db.test_tunnel(&source, Some("secret".into())).await;
|
||||
env.db.trust_ssh_host("pg").await.unwrap();
|
||||
|
||||
let err = env.db.test_tunnel(&source, Some("secret".into())).await.unwrap_err();
|
||||
assert!(err.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", err.message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_source_without_a_tunnel_has_nothing_to_test() {
|
||||
let env = env().await;
|
||||
let mut source = env.source(5432);
|
||||
source.ssh.as_mut().unwrap().enabled = false;
|
||||
let err = env.db.test_tunnel(&source, None).await.unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::InvalidInput, "{err:?}");
|
||||
}
|
||||
@@ -42,6 +42,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
test(&c.host, &c.window, source, password, ssh_secret).await
|
||||
});
|
||||
d.add("db_test_tunnel", |c| async move {
|
||||
let (source, ssh_secret) = (c.arg::<DataSource>("source")?, c.arg::<Option<String>>("sshSecret")?);
|
||||
test_tunnel(&c.host, &c.window, source, ssh_secret).await
|
||||
});
|
||||
d.add("db_set_ssh_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
set_ssh_secret(&c.host, &c.window, &id, secret, save).await
|
||||
@@ -212,6 +216,17 @@ pub async fn test(
|
||||
db.test(&source, password, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Checks the SSH tunnel of `source` alone: SSH login and the SSH host reaching the database.
|
||||
pub async fn test_tunnel(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
source: DataSource,
|
||||
ssh_secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.test_tunnel(&source, ssh_secret).await
|
||||
}
|
||||
|
||||
/// Remembers the SSH password / key passphrase of a data source (see `set_password`).
|
||||
pub async fn set_ssh_secret(host: &Host, window: &str, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
|
||||
@@ -372,6 +372,25 @@ pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessi
|
||||
host.debugger.restart_frame(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_custom_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
ids: Vec<String>,
|
||||
xhr: Vec<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.js_set_custom_breakpoints(session, ids, xhr).await
|
||||
}
|
||||
|
||||
pub async fn debug_js_response_body(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
request_id: String,
|
||||
) -> IdeResult<ide_dap::JsResponseBody> {
|
||||
host.debugger.js_response_body(session, request_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
@@ -588,6 +607,12 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_js_response_body", |c| async move {
|
||||
debug_js_response_body(&c.host, &c.window, c.arg("session")?, c.arg("requestId")?).await
|
||||
});
|
||||
d.add("debug_js_custom_breakpoints", |c| async move {
|
||||
debug_js_custom_breakpoints(&c.host, &c.window, c.arg("session")?, c.arg("ids")?, c.arg("xhr")?).await
|
||||
});
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
|
||||
@@ -74,9 +74,15 @@ pub async fn lsp_completion(
|
||||
host.end_request(request);
|
||||
// Plugin providers (`[[contributes.providers]]`) add theirs.
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
|
||||
let items = plugin_call(move || plugins.provide_completion(&d, pos, trigger.as_deref())).await.unwrap_or_default();
|
||||
let (covered, items) = plugin_call(move || plugins.provide_completion_for_editor(&d, pos, trigger.as_deref()))
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if items.is_empty() {
|
||||
return Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }));
|
||||
return if covered {
|
||||
Ok(result.unwrap_or(CompletionList { is_incomplete: true, items: Vec::new() }))
|
||||
} else {
|
||||
result
|
||||
};
|
||||
}
|
||||
let mut list = result.unwrap_or(CompletionList { is_incomplete: false, items: Vec::new() });
|
||||
list.items.extend(items.into_iter().map(|(_, item)| plugin_completion(item)));
|
||||
@@ -309,9 +315,13 @@ 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>> {
|
||||
let target = position_document(host, window, doc, pos).await?;
|
||||
// ⌘B on a declaration shows its usages, also when a plugin's index links the name elsewhere
|
||||
// (a MyBatis mapper method to its XML statement): the server's answer comes along for the
|
||||
// editor to tell.
|
||||
if target == doc
|
||||
&& kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
&& !on_declaration_name(host, window, doc, pos).await
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
// A notebook cell is a language-server document only: no plugin navigates in it.
|
||||
@@ -352,6 +362,30 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
definition_targets(result, found, in_string)
|
||||
}
|
||||
|
||||
/// Whether the syntax tree puts the identifier at `pos` as a declaration's name (`int f()`'s `f`).
|
||||
async fn on_declaration_name(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(false);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let Ok(Some(at)) = project.structure.node_at(&s, Range::new(pos, pos), &cancel) else { return Ok(false) };
|
||||
Ok(is_declaration_name(&at))
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_declaration_name(at: &ide_syntax::NodeAt) -> bool {
|
||||
at.node.field.as_deref() == Some("name")
|
||||
&& at.ancestors.first().is_some_and(|parent| {
|
||||
["declaration", "definition", "declarator"].iter().any(|kind| parent.kind.ends_with(kind))
|
||||
})
|
||||
}
|
||||
|
||||
async fn in_string_literal(host: &Host, window: &str, doc: DocId, pos: Pos) -> bool {
|
||||
let Ok(project) = host.project(window) else { return false };
|
||||
blocking(move || {
|
||||
@@ -2136,6 +2170,24 @@ mod tests {
|
||||
assert_eq!(paths(definition_targets(Ok(vec![helper, guess]), Vec::new(), false)), ["/p/app/helpers.php"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn declaration_names_are_told_apart_from_uses() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
let structure = ide_syntax::StructureService::new(std::sync::Arc::new(ide_language::SyntaxCache::new(syntax)));
|
||||
let java = ide_core::LanguageId::new("java");
|
||||
let text = "interface ShareMapper {\n int batchCancel(List<Long> ids);\n}\nclass S { int f(ShareMapper m) { return m.batchCancel(null); } }\n";
|
||||
let snap = ide_syntax::Snapshot::text(&java, text);
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
let at = |line, col| {
|
||||
let pos = Pos::new(line, col);
|
||||
is_declaration_name(&structure.node_at(&snap, Range::new(pos, pos), &cancel).unwrap().unwrap())
|
||||
};
|
||||
assert!(at(1, 10), "the mapper method's name");
|
||||
assert!(at(0, 12), "the interface's name");
|
||||
assert!(!at(3, 45), "a call of the method");
|
||||
assert!(!at(1, 4), "its return type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_inside_string_literals_are_told_apart() {
|
||||
let syntax = std::sync::Arc::new(ide_language::Syntax::new());
|
||||
|
||||
@@ -31,6 +31,7 @@ pub mod scaffold;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
mod ssh;
|
||||
pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
@@ -95,6 +96,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
deploy::register(d);
|
||||
docker::register(d);
|
||||
db::register(d);
|
||||
ssh::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//! SSH configuration commands shared by the desktop shell's IPC commands and the remote
|
||||
//! server ([`crate::rpc`]). A remote window connects from the server (deployment, database
|
||||
//! tunnels, run targets, SSH sessions), so the host keys it trusts and the passwords it is
|
||||
//! given belong to the server's [`ide_ssh::SshManager`], not the desktop's.
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::remote_settings::SshConfiguration;
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("ssh_test", |c| async move {
|
||||
let (config, secret) = (c.arg::<SshConfiguration>("config")?, c.arg::<Option<String>>("secret")?);
|
||||
c.host.ssh.test(&config, secret).await
|
||||
});
|
||||
d.add("ssh_set_secret", |c| async move {
|
||||
let (id, secret, save) = (c.arg::<String>("id")?, c.arg::<Option<String>>("secret")?, c.arg::<bool>("save")?);
|
||||
c.host.ssh.set_secret(&id, secret, save);
|
||||
Ok(())
|
||||
});
|
||||
d.add("ssh_password_saved", |c| async move { Ok(c.host.ssh.password_saved(&c.arg::<String>("key")?)) });
|
||||
d.add("ssh_needs_secret", |c| async move {
|
||||
let ssh = &c.host.ssh;
|
||||
Ok(ssh.needs_secret(&ssh.config(&c.arg::<String>("id")?)?))
|
||||
});
|
||||
d.add("ssh_trust_host", |c| async move { c.host.ssh.trust_host(&c.arg::<String>("id")?).await });
|
||||
d.add("ssh_connected", |c| async move { Ok(c.host.ssh.connected()) });
|
||||
d.add("ssh_disconnect", |c| async move {
|
||||
c.host.ssh.disconnect(&c.arg::<String>("id")?);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
@@ -6,7 +6,7 @@ use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::Sdk;
|
||||
use ide_tools::jdk::{JdkReleases, JdkVendor};
|
||||
use ide_tools::{InstallEvent, ToolInfo, Tools};
|
||||
use ide_tools::{InstallEvent, ToolInfo, ToolUpdate, Tools};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
@@ -89,6 +89,16 @@ pub async fn tools_install(
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Newer versions of the tools the IDE installed (release downloads and npm packages).
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
pub async fn tools_updates(host: &Host, window: &str, request: RequestId) -> IdeResult<Vec<ToolUpdate>> {
|
||||
let tools = tools(host, window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.updates(&cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Removes a tool the IDE installed.
|
||||
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
@@ -185,6 +195,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("tools_updates", |c| async move { tools_updates(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
|
||||
d.add("jdk_download_releases", |c| async move {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
|
||||
@@ -22,6 +22,27 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Changes of `paths` only: the dirty-scope refresh after a watcher batch. `paths` that belong
|
||||
/// to no repository are ignored, and an empty list is the full status.
|
||||
pub async fn status_paths(host: &Host, window: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
}
|
||||
for r in repos.all() {
|
||||
r.set_ignored_names(settings.ignored_names());
|
||||
}
|
||||
repos.status_paths(&paths)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
|
||||
@@ -585,6 +585,21 @@ pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeRes
|
||||
blocking(move || per_backend(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
/// Adds `commit` to annotate's ignore list in `path`'s repository (the file `blame.ignoreRevsFile`
|
||||
/// names, else `.git-blame-ignore-revs`, which the local config then names).
|
||||
pub async fn vcs_ignore_blame_revision(host: &Host, window: &str, path: PathBuf, commit: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let result = project.with_disk_sync(|| project.with_repo_of(&path, |r| r.ignore_blame_revision(&commit)));
|
||||
if result.is_ok() {
|
||||
project.sink.emit(ide_core::IdeEvent::VcsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Project-host operation (also registered with the remote dispatcher).
|
||||
/// Returns the ignore files that were updated, so the UI can show them.
|
||||
pub async fn vcs_add_to_ignore(
|
||||
@@ -792,12 +807,19 @@ pub async fn vcs_stash_push(
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let paths = paths.unwrap_or_default();
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index)
|
||||
if paths.is_empty() {
|
||||
return project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, &[])
|
||||
});
|
||||
}
|
||||
per_repo(&project, &paths, Some("Git stash"), |r, paths| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index, paths)
|
||||
})
|
||||
})
|
||||
.await
|
||||
@@ -1462,6 +1484,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_add_to_ignore", |c| async move {
|
||||
vcs_add_to_ignore(&c.host, &c.window, c.arg("paths")?, c.arg("destination")?).await
|
||||
});
|
||||
d.add("vcs_ignore_blame_revision", |c| async move {
|
||||
vcs_ignore_blame_revision(&c.host, &c.window, c.arg("path")?, c.arg("commit")?).await
|
||||
});
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
@@ -1507,7 +1532,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_rename_branch", |c| async move {
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unset_upstream")?)
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("unsetUpstream")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
@@ -1520,6 +1545,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
c.arg("message")?,
|
||||
c.arg("includeUntracked")?,
|
||||
c.arg("keepIndex")?,
|
||||
c.arg("paths")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
|
||||
@@ -2,10 +2,106 @@
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::proxy_settings::{ProxyMode, ProxySettings};
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Whether `url` names this machine (`localhost` or a loopback address).
|
||||
fn is_loopback(url: &reqwest::Url) -> bool {
|
||||
let host = url.host_str().unwrap_or_default().trim_start_matches('[').trim_end_matches(']');
|
||||
host.eq_ignore_ascii_case("localhost") || host.parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback())
|
||||
}
|
||||
|
||||
fn is_html(content_type: &str) -> bool {
|
||||
let kind = content_type.split(';').next().unwrap_or_default().trim();
|
||||
kind.eq_ignore_ascii_case("text/html") || kind.eq_ignore_ascii_case("application/xhtml+xml")
|
||||
}
|
||||
|
||||
/// A client that reaches this machine only: no proxy, and a redirect is followed while it
|
||||
/// stays here (a dev server sending `/` to `/app/`).
|
||||
fn loopback_client() -> IdeResult<reqwest::Client> {
|
||||
let direct = ProxySettings { mode: ProxyMode::None, ..ProxySettings::default() };
|
||||
let stay = reqwest::redirect::Policy::custom(|attempt| {
|
||||
if is_loopback(attempt.url()) && attempt.previous().len() < 5 { attempt.follow() } else { attempt.stop() }
|
||||
});
|
||||
ide_http::client::build_with_redirect(&direct, stay)
|
||||
}
|
||||
|
||||
/// Whether `url`, a server on this machine, answers a browser with an HTML document: a page
|
||||
/// to open, as a dev server has, and not an API endpoint. Other hosts are never asked.
|
||||
pub async fn serves_page(url: &str) -> bool {
|
||||
let Ok(url) = reqwest::Url::parse(url) else { return false };
|
||||
if !matches!(url.scheme(), "http" | "https") || !is_loopback(&url) {
|
||||
return false;
|
||||
}
|
||||
let Ok(client) = loopback_client() else { return false };
|
||||
// A dev server may compile the page on this first request.
|
||||
let request = client.get(url).header(reqwest::header::ACCEPT, "text/html").timeout(Duration::from_secs(10));
|
||||
let Ok(response) = request.send().await else { return false };
|
||||
let html = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.is_some_and(is_html);
|
||||
response.status().is_success() && html
|
||||
}
|
||||
|
||||
/// The URL serving `file`, under the window's project when it holds the file.
|
||||
pub async fn url(host: &Host, window: &str, file: &Path) -> IdeResult<String> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
host.web.url_for(root.as_deref(), file).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A server on a free port that answers every request with `status` and `header`. It
|
||||
/// listens on every interface, so that a test can reach it under a name that is not loopback.
|
||||
async fn answering(status: &'static str, header: String) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut socket, _)) = listener.accept().await {
|
||||
let mut request = [0u8; 2048];
|
||||
let _ = socket.read(&mut request).await;
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\n{header}\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok");
|
||||
let _ = socket.write_all(response.as_bytes()).await;
|
||||
}
|
||||
});
|
||||
format!("http://127.0.0.1:{port}/")
|
||||
}
|
||||
|
||||
async fn server(status: &'static str, content_type: &'static str) -> String {
|
||||
answering(status, format!("Content-Type: {content_type}")).await
|
||||
}
|
||||
|
||||
async fn redirecting_to(location: &str) -> String {
|
||||
answering("302 Found", format!("Location: {location}")).await
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_redirect_is_followed_on_this_machine_only() {
|
||||
let page = server("200 OK", "text/html").await;
|
||||
assert!(serves_page(&redirecting_to(&page).await).await);
|
||||
// The same page under an address that is not loopback: it is not asked.
|
||||
let elsewhere = page.replace("127.0.0.1", "0.0.0.0");
|
||||
assert!(!serves_page(&redirecting_to(&elsewhere).await).await);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn a_page_is_an_html_answer_from_this_machine() {
|
||||
assert!(serves_page(&server("200 OK", "text/html; charset=utf-8").await).await);
|
||||
assert!(serves_page(&server("200 OK", "Text/HTML").await.replace("127.0.0.1", "localhost")).await);
|
||||
// An API, an error page and a port nothing listens on are not pages.
|
||||
assert!(!serves_page(&server("200 OK", "application/json").await).await);
|
||||
assert!(!serves_page(&server("404 Not Found", "text/html").await).await);
|
||||
assert!(!serves_page("http://127.0.0.1:1/").await);
|
||||
// Other machines are not asked at all.
|
||||
assert!(!serves_page("http://example.com/").await);
|
||||
assert!(!serves_page("ftp://localhost/").await);
|
||||
assert!(!serves_page("not a url").await);
|
||||
}
|
||||
}
|
||||
@@ -1642,6 +1642,9 @@ impl HostApi for PluginApi {
|
||||
) -> Result<(), HostError> {
|
||||
self.set_decorations(path, set, Vec::new(), item, true)
|
||||
}
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
self.set_file_icon_decorations(set, items)
|
||||
}
|
||||
fn vcs_log(
|
||||
&self,
|
||||
root: &Path,
|
||||
@@ -4124,6 +4127,9 @@ impl PluginApi {
|
||||
{
|
||||
return Err(HostError::Invalid("a file note is one non-empty line of at most 200 characters".into()));
|
||||
}
|
||||
if let Some(icon) = file.as_ref().and_then(|f| f.icon.as_ref()) {
|
||||
ide_extension::validate_item_id(icon, "file icon id").map_err(HostError::Invalid)?;
|
||||
}
|
||||
items.retain(|d| decoration_style(&d.style));
|
||||
if items.iter().any(|d| d.range.start > d.range.end) {
|
||||
return Err(HostError::Invalid("reversed decoration range".into()));
|
||||
@@ -4132,24 +4138,14 @@ impl PluginApi {
|
||||
// The decoration follows the file, not the window's project: editors render
|
||||
// whatever they have open, including files outside every project root. The
|
||||
// set's root stays the path's own (deepest open) project for tree filtering.
|
||||
let root = self
|
||||
.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()));
|
||||
let root = self.decoration_root(path);
|
||||
Some(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: ide_core::IpcPath(path.to_path_buf()),
|
||||
set_id: set.into(),
|
||||
tree,
|
||||
items,
|
||||
file: file.filter(|f| f.note.is_some() || decoration_style(&f.style)),
|
||||
file: file.filter(|f| f.note.is_some() || f.icon.is_some() || decoration_style(&f.style)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -4177,6 +4173,60 @@ impl PluginApi {
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn decoration_root(&self, path: &Path) -> PathBuf {
|
||||
self.shared
|
||||
.workspace()
|
||||
.map(|w| {
|
||||
w.projects()
|
||||
.into_iter()
|
||||
.filter(|root| path.starts_with(root))
|
||||
.max_by_key(|root| root.components().count())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
.unwrap_or_else(|| path.parent().map(Path::to_path_buf).unwrap_or_else(|| path.to_path_buf()))
|
||||
}
|
||||
|
||||
fn set_file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
if set.is_empty() || set.len() > 128 {
|
||||
return Err(HostError::Invalid("set id must be 1-128 bytes".into()));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Err(HostError::Invalid("at most 10000 file icons per set".into()));
|
||||
}
|
||||
let mut by_path = BTreeMap::new();
|
||||
for (path, icon) in items {
|
||||
ide_extension::validate_item_id(&icon, "file icon id").map_err(HostError::Invalid)?;
|
||||
by_path.insert(path, icon);
|
||||
}
|
||||
let mut all = self.shared.decorations.lock();
|
||||
let mut sets = all.get(self.id()).cloned().unwrap_or_default();
|
||||
sets.retain(|s| !(s.tree && s.set_id == set));
|
||||
for (path, icon) in by_path {
|
||||
let root = self.decoration_root(&path);
|
||||
sets.push(super::DecorationSet {
|
||||
root: root.into(),
|
||||
path: ide_core::IpcPath(path),
|
||||
set_id: set.into(),
|
||||
tree: true,
|
||||
items: Vec::new(),
|
||||
file: Some(ide_plugin::FileDecoration {
|
||||
badge: None,
|
||||
style: String::new(),
|
||||
tooltip: None,
|
||||
note: None,
|
||||
icon: Some(icon),
|
||||
}),
|
||||
});
|
||||
}
|
||||
let names: BTreeSet<_> = sets.iter().filter(|s| s.tree).map(|s| &s.set_id).collect();
|
||||
if names.len() > 50 || serde_json::to_vec(&sets).map_err(failed)?.len() > 4 << 20 {
|
||||
return Err(HostError::Invalid("decoration sets exceed plugin limits".into()));
|
||||
}
|
||||
all.insert(self.id().into(), sets.clone());
|
||||
self.shared.sink.emit(None, PluginEvent::DecorationsChanged { plugin_id: self.id().into(), sets });
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
fn decoration_style(style: &str) -> bool {
|
||||
matches!(
|
||||
@@ -4330,7 +4380,8 @@ mod workbench_tests {
|
||||
badge: Some("abc".into()),
|
||||
style: "info".into(),
|
||||
tooltip: None,
|
||||
note: None
|
||||
note: None,
|
||||
icon: None,
|
||||
})
|
||||
)
|
||||
.is_err()
|
||||
@@ -4340,6 +4391,7 @@ mod workbench_tests {
|
||||
style: String::new(),
|
||||
tooltip: Some("why".into()),
|
||||
note: Some(text.into()),
|
||||
icon: None,
|
||||
};
|
||||
api.file_decorations(Some(&path), "notes", Some(note("entry point"))).unwrap();
|
||||
let notes = |api: &PluginApi| {
|
||||
@@ -4353,6 +4405,18 @@ mod workbench_tests {
|
||||
for bad in ["", "two\nlines", &"x".repeat(201)] {
|
||||
assert!(api.file_decorations(Some(&path), "notes", Some(note(bad))).is_err(), "{bad:?}");
|
||||
}
|
||||
api.file_icon_decorations(
|
||||
"icons",
|
||||
vec![(path.clone(), "helm-chart".into()), (root.join("chart"), "helm-dir".into())],
|
||||
)
|
||||
.unwrap();
|
||||
let icons = api.shared.decoration_sets()["test"]
|
||||
.iter()
|
||||
.filter(|s| s.tree && s.set_id == "icons")
|
||||
.filter_map(|s| s.file.as_ref().and_then(|f| f.icon.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(icons.len(), 2);
|
||||
assert!(api.file_icon_decorations("icons", vec![(path.clone(), "BAD ID".into())]).is_err());
|
||||
api.shared.clear_plugin("test", true);
|
||||
assert!(api.shared.decoration_sets().is_empty());
|
||||
}
|
||||
|
||||
@@ -396,15 +396,37 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn has_document_providers(&self, kind: ProviderKind, doc: &ProviderDocument) -> bool {
|
||||
self.provider_plugins(kind, &doc.language)
|
||||
.into_iter()
|
||||
.any(|p| p.dev_root.as_ref().is_none_or(|r| doc.path.starts_with(r)))
|
||||
}
|
||||
|
||||
pub fn provide_completion(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> Vec<(String, CompletionItem)> {
|
||||
self.feature_documents(doc, Some(at))
|
||||
self.provide_completion_for_editor(doc, at, trigger).1
|
||||
}
|
||||
|
||||
pub(crate) fn provide_completion_for_editor(
|
||||
&self,
|
||||
doc: &ProviderDocument,
|
||||
at: ide_core::Pos,
|
||||
trigger: Option<&str>,
|
||||
) -> (bool, Vec<(String, CompletionItem)>) {
|
||||
// An injection-capable outer document (e.g. SQL embedded in text) may complete at other
|
||||
// positions even when `at` is outside an injection, so avoid marking document-wide backoff.
|
||||
let mut covered = self.has_document_providers(ProviderKind::Injections, doc);
|
||||
let items = self
|
||||
.feature_documents(doc, Some(at))
|
||||
.into_iter()
|
||||
.flat_map(|(doc, bounds)| {
|
||||
if self.has_document_providers(ProviderKind::Completion, &doc) {
|
||||
covered = true;
|
||||
}
|
||||
let (d, t) = (Arc::new(doc.clone()), trigger.map(str::to_owned));
|
||||
self.document_calls(ProviderKind::Completion, &doc, move |rt, timeout| {
|
||||
rt.provide_completion(&d, at, t.as_deref(), timeout)
|
||||
@@ -419,7 +441,8 @@ impl PluginHost {
|
||||
Some((p, i))
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
.collect();
|
||||
(covered, items)
|
||||
}
|
||||
|
||||
pub fn provide_code_actions(
|
||||
|
||||
@@ -743,6 +743,7 @@ pub(super) fn custom_editor(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct FileIconDecl {
|
||||
id: String,
|
||||
#[serde(default)]
|
||||
patterns: Vec<String>,
|
||||
icon: PathBuf,
|
||||
#[serde(default)]
|
||||
@@ -754,13 +755,19 @@ pub fn file_icon_asset(id: &str, dark: bool) -> String {
|
||||
format!("fileIcon/{id}/{}", if dark { "dark" } else { "light" })
|
||||
}
|
||||
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns`,
|
||||
/// whatever icon theme is in use (the user's own file type icons still win).
|
||||
/// `[[contributes.fileIcons]]`: an icon for the files matching `patterns` (or
|
||||
/// assigned dynamically through `decorations.set-file-icon` when `patterns` is
|
||||
/// omitted in API 1.8), whatever icon theme is in use (the user's own file type
|
||||
/// icons still win).
|
||||
pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: FileIconDecl = decode(FILE_ICONS, decl)?;
|
||||
validate_item_id(&d.id, "file icon id")?;
|
||||
let what = format!("file icon {:?}", d.id);
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
if d.patterns.is_empty() {
|
||||
pkg.manifest.needs_api("contributes.fileIcons without patterns", 8)?;
|
||||
} else {
|
||||
file_patterns(&d.patterns, &what)?;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for (path, dark) in [(Some(&d.icon), false), (d.icon_dark.as_ref(), true)] {
|
||||
@@ -769,8 +776,16 @@ pub(super) fn file_icon(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Stri
|
||||
assets.push((file_icon_asset(&d.id, dark), svg));
|
||||
files.push(path.clone());
|
||||
}
|
||||
let json = serde_json::json!({ "id": d.id, "patterns": d.patterns, "dark": d.icon_dark.is_some() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.patterns.join(", "), json);
|
||||
let bytes = serde_json::to_vec(&assets).map_err(|e| e.to_string())?;
|
||||
let revision = blake3::hash(&bytes).to_hex().to_string();
|
||||
let json = serde_json::json!({
|
||||
"id": d.id,
|
||||
"patterns": d.patterns,
|
||||
"dark": d.icon_dark.is_some(),
|
||||
"revision": revision,
|
||||
});
|
||||
let label = if d.patterns.is_empty() { d.id.clone() } else { d.patterns.join(", ") };
|
||||
let mut out = UiDecl::new(d.id.clone(), label, json);
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
|
||||
@@ -447,6 +447,11 @@ impl Host {
|
||||
.collect();
|
||||
matches!(rest.as_slice(), [first, ..] if first == "worktrees" && rest.len() <= 2)
|
||||
});
|
||||
// A repository folder moved or restored into the project: no `.git` event is
|
||||
// written inside it before the watcher sees the new directory, its creation is
|
||||
// the signal. A `git clone` / `git init` inside the project already arrives
|
||||
// through the `.git` paths above; this covers the whole tree being brought in.
|
||||
git_dirs |= batch.created.iter().any(|p| p.join(".git").exists());
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink, &build_cache);
|
||||
}
|
||||
@@ -864,6 +869,20 @@ fn scan_repositories(
|
||||
sink: &SharedSink,
|
||||
build_cache: &Path,
|
||||
) {
|
||||
match discover_repositories(root, settings, build_cache) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("looking for git repositories: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The repository containing `root` and those nested below it or below a content root.
|
||||
/// The project-open scan and the watcher's rescan share these skip rules.
|
||||
fn discover_repositories(root: &Path, settings: &SettingsStore, build_cache: &Path) -> IdeResult<Repos> {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| {
|
||||
p.file_name().is_some_and(|n| {
|
||||
@@ -875,15 +894,7 @@ fn scan_repositories(
|
||||
};
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root, build_cache));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("looking for git repositories: {e}"),
|
||||
}
|
||||
Repos::discover_roots(&roots, &skip)
|
||||
}
|
||||
|
||||
impl Project {
|
||||
|
||||
@@ -270,7 +270,8 @@ impl Dispatcher {
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await })
|
||||
.add("vcs_status_paths", |c| async move { ops::vcs::status_paths(&c.host, &c.window, c.arg("paths")?).await });
|
||||
ops::register_all(&mut d);
|
||||
d
|
||||
}
|
||||
|
||||
@@ -475,6 +475,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_status_paths"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
|
||||
@@ -99,6 +99,21 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
|
||||
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();
|
||||
// `a.toml` has neither a language server, a completion plugin, nor an injection plugin, so `Unavailable` is preserved.
|
||||
let toml_path = root.join("a.toml");
|
||||
std::fs::write(&toml_path, "k = 1\n").unwrap();
|
||||
let toml_doc = ops::doc::open(&host, "main", toml_path).await.unwrap();
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_completion(&host, "main", RequestId(89), toml_doc.id, Pos::default(), None)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Unavailable
|
||||
);
|
||||
let sql_path = root.join("q.sql");
|
||||
std::fs::write(&sql_path, "SELECT 1;").unwrap();
|
||||
let sql_doc = ops::doc::open(&host, "main", sql_path).await.unwrap();
|
||||
assert!(ops::lsp::lsp_completion(&host, "main", RequestId(90), sql_doc.id, Pos::default(), None).await.is_ok());
|
||||
assert_eq!(
|
||||
ops::lsp::lsp_signature_help(&host, "main", doc.id, Pos::default()).await.unwrap().unwrap().signatures[0]
|
||||
.parameters,
|
||||
|
||||
@@ -821,8 +821,10 @@ fn a_reporting_task_outlives_its_background_budget() {
|
||||
|
||||
#[test]
|
||||
fn background_tasks_scale_and_renew_cpu_fuel_budget() {
|
||||
// Room for activation (about 3_100 on macOS, more on Windows) but not for `patient`'s
|
||||
// whole run (about 9_000) without the refill on each report.
|
||||
let f = fixture(Limits {
|
||||
fuel: 3_500,
|
||||
fuel: 6_000,
|
||||
timeout: Duration::from_millis(500),
|
||||
background: Duration::from_millis(5_000),
|
||||
..Limits::default()
|
||||
@@ -831,14 +833,14 @@ fn background_tasks_scale_and_renew_cpu_fuel_budget() {
|
||||
if f.host.set_capability("misbehave", "process:git", true).is_err() {
|
||||
return;
|
||||
}
|
||||
// Warm up activation in its own call so `patient` starts with a fresh 3_500-fuel budget.
|
||||
// Warm up activation in its own call so `patient` starts with a fresh budget.
|
||||
assert_eq!(f.run("misbehave", "echo", Some("warm")).unwrap().as_deref(), Some("warm"));
|
||||
|
||||
let out = f.run("misbehave", "patient", None).expect("background task succeeds").expect("output");
|
||||
assert_eq!(out, "still here");
|
||||
|
||||
let err = f.run("misbehave", "spin", None).unwrap_err();
|
||||
assert!(err.contains("exceeded its CPU budget (3500 fuel per call)"), "{err}");
|
||||
assert!(err.contains("exceeded its CPU budget (6000 fuel per call)"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2554,7 +2556,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
f.install(&official("git-toolbox"));
|
||||
// Focusing an editor wakes it to show the branch (nothing fetched yet).
|
||||
let now = Instant::now();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some("src/notes.txt".into()))]);
|
||||
let focused = f.project.join("src/notes.txt").to_string_lossy().into_owned();
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::EditorFocused(Some(focused))]);
|
||||
f.host.flush_events_at(now + Duration::from_secs(3));
|
||||
assert!(f.host.wait_events(Duration::from_secs(10)));
|
||||
let item = |host: &PluginHost| {
|
||||
@@ -2992,13 +2995,18 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
f.host.view_event("openidea.csv-editor", "table", cell, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n");
|
||||
|
||||
// Add Row appends, also on wasm32 (where its row index used to wrap to the header).
|
||||
let add = ide_plugin::ViewEvent::Button { id: "add-row".into() };
|
||||
f.host.view_event("openidea.csv-editor", "table", add, inv.clone()).unwrap();
|
||||
assert_eq!(f.workspace.text("people.csv"), "name,city\nAda,\"Paris, FR\"\nBob,Oslo\n,\n");
|
||||
|
||||
// Settings: without a header row every record is a row.
|
||||
let mut values = serde_json::Map::new();
|
||||
values.insert("firstRowIsHeader".into(), serde_json::Value::Bool(false));
|
||||
f.workspace.settings.lock().insert("openidea.csv-editor".into(), values);
|
||||
let view = f.host.render_view("openidea.csv-editor", "table", None, inv.clone()).unwrap();
|
||||
let ide_plugin::ViewBody::Table(t) = &view.body else { panic!("{view:?}") };
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 3));
|
||||
assert_eq!((t.columns[0].as_str(), t.total_rows), ("Column 1", 4));
|
||||
|
||||
// Undeclared views are refused before any code runs.
|
||||
let err = f.host.render_view("openidea.csv-editor", "other", None, inv).unwrap_err();
|
||||
@@ -3754,6 +3762,43 @@ path = "templates/new.draw"
|
||||
assert!(f.host.web_page("web-demo", "draw").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_icons_without_patterns_require_api_1_8_and_project_assets() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = tmp.path().join("dynamic-icons");
|
||||
std::fs::create_dir_all(pkg.join("icons")).unwrap();
|
||||
std::fs::write(pkg.join("icons/helm-dir.svg"), r#"<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="6"/></svg>"#)
|
||||
.unwrap();
|
||||
let write_manifest = |api: &str| {
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "dynamic-icons"
|
||||
name = "Dynamic Icons"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "{api}"
|
||||
|
||||
[[contributes.fileIcons]]
|
||||
id = "helm-dir"
|
||||
icon = "icons/helm-dir.svg"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
};
|
||||
write_manifest("^1.7");
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("1.8"), "{err:?}");
|
||||
|
||||
write_manifest("^1.8");
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let assets = f.host.assets("dynamic-icons", "fileIcon/helm-dir/").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<circle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_templates_fixture_installs_without_write_capabilities() {
|
||||
let f = fixture(Limits::default());
|
||||
|
||||
@@ -367,6 +367,7 @@ async fn remote_projects_get_plugin_views_and_custom_editors() {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop() {
|
||||
use ide_core::{Diagnostic, Pos, Range, Severity};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let desktop = Arc::new(
|
||||
@@ -426,42 +427,70 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
"severity": "warning", "message": message, "source": "remote-check", "code": null
|
||||
}]})
|
||||
};
|
||||
let publish = |path: &Path, source: &str, message: Option<&str>| {
|
||||
let items = message
|
||||
.map(|message| Diagnostic {
|
||||
range: Range::new(Pos::new(0, 1), Pos::new(0, 3)),
|
||||
severity: Severity::Warning,
|
||||
message: message.into(),
|
||||
source: Some(source.into()),
|
||||
code: None,
|
||||
unnecessary: false,
|
||||
deprecated: false,
|
||||
server: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
host.project("main").unwrap().sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.to_path_buf().into(),
|
||||
source: source.into(),
|
||||
items,
|
||||
});
|
||||
};
|
||||
let files = json!([
|
||||
problem(&remote_file, "remote warning"),
|
||||
problem(&other_file, "other warning"),
|
||||
problem(&outside_file, "outside warning")
|
||||
]);
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
// UI reports are not the source of the plugin's host-owned snapshot.
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
publish(&other_file, "remote-check", Some("other warning"));
|
||||
publish(&outside_file, "remote-check", Some("outside warning"));
|
||||
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains("outside warning"), "{problems}");
|
||||
for path in ["remote.txt", remote_file.to_str().unwrap()] {
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", Some(path))).await.unwrap();
|
||||
let problems = problems.as_str().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(!problems.contains("other warning"), "{problems}");
|
||||
assert!(!problems.contains("outside warning"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
}
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", Some("missing.txt"))).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": false, "files": [{"path": remote_file, "items": []}]}))
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("second warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
publish(&remote_file, "remote-check", None);
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("remote warning"));
|
||||
assert!(problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(
|
||||
&peer,
|
||||
"mcp_report_problems",
|
||||
json!({"reset": true, "files": [problem(&remote_file, "replacement warning")]}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
publish(&remote_file, "second-check", Some("replacement warning"));
|
||||
let problems = invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap();
|
||||
assert!(problems.as_str().unwrap().contains("replacement warning"));
|
||||
assert!(!problems.as_str().unwrap().contains("other warning"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": []})).await.unwrap();
|
||||
assert!(!problems.as_str().unwrap().contains("second warning"));
|
||||
assert!(problems.as_str().unwrap().contains("other warning"));
|
||||
publish(&remote_file, "second-check", None);
|
||||
publish(&other_file, "remote-check", None);
|
||||
publish(&outside_file, "remote-check", None);
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "plugin_run_command", run("open-editor", Some("remote.txt"))).await.unwrap();
|
||||
invoke(&peer, "plugin_run_command", run("decorations", remote_file.to_str())).await.unwrap();
|
||||
@@ -513,11 +542,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
stream.read_to_string(&mut response).await.unwrap();
|
||||
assert!(response.contains("You can close this tab"));
|
||||
assert_eq!(oauth.await.unwrap().unwrap(), json!("code=desktop%2Bcode"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "plugin_run_command", run("problems", None)).await.unwrap(), json!("[]"));
|
||||
invoke(&peer, "mcp_report_problems", json!({"reset": true, "files": files})).await.unwrap();
|
||||
publish(&remote_file, "remote-check", Some("remote warning"));
|
||||
peer.kill();
|
||||
tokio::time::timeout(Duration::from_secs(5), server).await.unwrap().unwrap();
|
||||
let plugin_host = plugins.host().clone();
|
||||
@@ -537,7 +566,11 @@ async fn api14_remote_calls_keep_browser_listeners_and_secrets_on_the_desktop()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.as_deref(), Some("[]"));
|
||||
// Disconnect clears desktop reports, not the still-live host project snapshot.
|
||||
let problems = result.as_deref().unwrap();
|
||||
assert!(problems.contains("remote warning"), "{problems}");
|
||||
assert!(problems.contains("remote-check"), "{problems}");
|
||||
assert!(problems.contains(remote_file.to_str().unwrap()), "{problems}");
|
||||
plugins.host().shutdown();
|
||||
desktop.shutdown();
|
||||
}
|
||||
|
||||
@@ -341,6 +341,7 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_save_sources",
|
||||
"db_set_password",
|
||||
"db_test",
|
||||
"db_test_tunnel",
|
||||
"db_set_ssh_secret",
|
||||
"db_trust_ssh_host",
|
||||
"db_connected",
|
||||
@@ -364,6 +365,13 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
"db_table_page",
|
||||
"db_apply_edits",
|
||||
"db_console_path",
|
||||
"ssh_test",
|
||||
"ssh_set_secret",
|
||||
"ssh_password_saved",
|
||||
"ssh_needs_secret",
|
||||
"ssh_trust_host",
|
||||
"ssh_connected",
|
||||
"ssh_disconnect",
|
||||
] {
|
||||
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
|
||||
}
|
||||
@@ -372,6 +380,12 @@ async fn local_deployment_runs_on_the_project_host() {
|
||||
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
// A remote window's host-key and password remedies reach the server's SSH manager,
|
||||
// the one its deployment / database connections failed in.
|
||||
let trust = invoke(&peer, "ssh_trust_host", json!({"id": "nothing-pending"})).await.unwrap_err();
|
||||
assert!(trust.message.contains("no host key to trust"), "{trust:?}");
|
||||
invoke(&peer, "ssh_set_secret", json!({"id": "deploy", "secret": "pw", "save": false})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "ssh_connected", json!({})).await.unwrap(), json!([]));
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
|
||||
@@ -481,3 +495,117 @@ async fn the_desktop_routes_exactly_what_a_server_of_this_build_answers() {
|
||||
assert!(rpc::client_commands().contains(&"term_spawn"));
|
||||
plugins.host().shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn renames_a_branch_with_the_clients_argument_names() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("repo");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let out = std::process::Command::new("git").args(args).current_dir(&root).output().unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
std::fs::write(root.join("a.txt"), "a\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "-m", "init"]);
|
||||
git(&["branch", "feature"]);
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(ide_core::NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "open_project", json!({"path": root})).await.unwrap();
|
||||
invoke(&peer, "project_trust_set", json!({"path": root, "trusted": true, "folder": false})).await.unwrap();
|
||||
// The argument names the desktop client sends (`vcsRenameBranch` in src/bindings.ts).
|
||||
invoke(
|
||||
&peer,
|
||||
"vcs_rename_branch",
|
||||
json!({"repo": null, "from": "feature", "to": "renamed", "unsetUpstream": false}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
git(&["branch", "--format=%(refname:short)", "--list"]).lines().collect::<Vec<_>>(),
|
||||
["main", "renamed"]
|
||||
);
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
|
||||
/// A handler reads its arguments by the names the desktop client sends: `src/bindings.ts`
|
||||
/// passes them in camelCase, and a name the client never sends reads as `null` (an
|
||||
/// `Option` silently `None`, anything else an "invalid type: null" error).
|
||||
#[test]
|
||||
fn handlers_read_the_argument_names_the_client_sends() {
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::path::Path;
|
||||
let repo = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let bindings = std::fs::read_to_string(repo.join("src/bindings.ts")).unwrap();
|
||||
let mut sent = HashMap::<&str, BTreeSet<&str>>::new();
|
||||
for part in bindings.split("__TAURI_INVOKE(\"").skip(1) {
|
||||
let (command, rest) = part.split_once('"').unwrap();
|
||||
let args = rest.strip_prefix(", {").and_then(|r| r.split_once('}')).map_or("", |(args, _)| args);
|
||||
let names = args.split(',').map(|a| a.split(':').next().unwrap().trim()).filter(|a| !a.is_empty());
|
||||
sent.insert(command, names.collect());
|
||||
}
|
||||
fn sources(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
|
||||
for entry in std::fs::read_dir(dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
if path.is_dir() {
|
||||
sources(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "rs") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
sources(&repo.join("crates/ide-host/src"), &mut files);
|
||||
let mut checked = 0;
|
||||
let mut wrong = Vec::new();
|
||||
for file in files {
|
||||
let src = std::fs::read_to_string(&file).unwrap();
|
||||
// `.add("name", …)`, also with the name on the next line.
|
||||
let starts: Vec<usize> = src
|
||||
.match_indices(".add(")
|
||||
.map(|(i, m)| i + m.len())
|
||||
.filter_map(|i| src[i..].trim_start().strip_prefix('"').map(|rest| src.len() - rest.len()))
|
||||
.collect();
|
||||
for (n, &start) in starts.iter().enumerate() {
|
||||
let body = &src[start..starts.get(n + 1).copied().unwrap_or(src.len())];
|
||||
let (command, body) = body.split_once('"').unwrap();
|
||||
let Some(sent) = sent.get(command) else { continue };
|
||||
checked += 1;
|
||||
for reader in ["c.arg", "c.sender", "c.channel"] {
|
||||
for (at, _) in body.match_indices(reader) {
|
||||
let rest = &body[at + reader.len()..];
|
||||
let rest = match rest.strip_prefix("::<") {
|
||||
Some(generic) => &generic[generic.find(">(").unwrap() + 1..],
|
||||
None => rest,
|
||||
};
|
||||
let Some(name) = rest.strip_prefix("(\"").and_then(|r| r.split_once('"')).map(|(n, _)| n) else {
|
||||
continue;
|
||||
};
|
||||
if !sent.contains(name) {
|
||||
wrong.push(format!("{command} reads `{name}`; the client sends {sent:?}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(checked > 400, "found only {checked} handlers with client bindings");
|
||||
assert!(wrong.is_empty(), "{wrong:#?}");
|
||||
}
|
||||
@@ -172,6 +172,7 @@ async fn restricted_vcs_commands_run_no_program_of_the_repository() {
|
||||
}
|
||||
let calls: Vec<Call> = vec![
|
||||
call!("vcs_status", "", ops::vcs::status(host, w)),
|
||||
call!("vcs_status_paths", "", ops::vcs::status_paths(host, w, vec![file.clone()])),
|
||||
sync!("vcs_roots", "", vcs::vcs_roots(host, w)),
|
||||
call!("vcs_branch_info", "", vcs::vcs_branch_info(host, w, root())),
|
||||
call!("vcs_line_hunks", "", vcs::vcs_line_hunks(host, w, file.clone())),
|
||||
|
||||
@@ -102,7 +102,7 @@ async fn stash_and_unstash_reload_clean_open_documents_before_returning() {
|
||||
let f = Fixture::new().await;
|
||||
let edited = format!("{ORIGINAL}\n");
|
||||
f.saved_edit(&edited);
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false).await.unwrap();
|
||||
vcs::vcs_stash_push(&f.host, "main", None, None, false, false, None).await.unwrap();
|
||||
f.assert_clean(ORIGINAL);
|
||||
vcs::vcs_stash_apply(&f.host, "main", None, 0, true, false, None).await.unwrap();
|
||||
f.assert_clean(&edited);
|
||||
|
||||
@@ -168,7 +168,7 @@ async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false, None).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
//! A repository folder moved into an open project is registered while the project is open:
|
||||
//! the folder brings no `.git` event of its own, so the watcher's "created path" is what the
|
||||
//! rescan has to answer to (the open scan already ran).
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::{path::Path, sync::Arc, time::Duration};
|
||||
|
||||
fn git_init(dir: &Path) {
|
||||
std::fs::create_dir_all(dir).unwrap();
|
||||
let status = std::process::Command::new("git").args(["init", "-q"]).current_dir(dir).status();
|
||||
assert!(status.is_ok_and(|status| status.success()), "git init in {}", dir.display());
|
||||
}
|
||||
|
||||
async fn wait_until(what: &str, mut done: impl FnMut() -> bool) {
|
||||
let deadline = tokio::time::Instant::now() + Duration::from_secs(15);
|
||||
while !done() {
|
||||
assert!(tokio::time::Instant::now() < deadline, "timed out waiting for {what}");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn moving_a_repository_into_the_project_registers_it() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
git_init(&root);
|
||||
// Prepared outside the project, as a restored or moved folder would be.
|
||||
let prepared = base.join("prepared");
|
||||
git_init(&prepared);
|
||||
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
// The scan on project open runs in the background: wait for it before moving anything in.
|
||||
wait_until("the project's own repository", || project.repos().roots().len() == 1).await;
|
||||
|
||||
std::fs::rename(&prepared, root.join("moved")).unwrap();
|
||||
wait_until("the moved repository to be registered", || {
|
||||
project.repos().roots().iter().any(|r| r.ends_with("moved"))
|
||||
})
|
||||
.await;
|
||||
}
|
||||
@@ -9,7 +9,7 @@ license.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -52,26 +52,35 @@ fn search_dirs(file: Option<&Path>, root: Option<&Path>) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn parse_env(text: &str) -> IdeResult<EnvMap> {
|
||||
use serde_json::value::RawValue;
|
||||
|
||||
let invalid = |e| IdeError::invalid(format!("invalid environment file: {e}"));
|
||||
// Editors on Windows (Notepad) save UTF-8 with a byte order mark, which serde_json rejects.
|
||||
let text = text.strip_prefix('\u{feff}').unwrap_or(text);
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("invalid environment file: {e}")))?;
|
||||
let value: Box<RawValue> = serde_json::from_str(text).map_err(invalid)?;
|
||||
let mut out = EnvMap::new();
|
||||
let Some(envs) = value.as_object() else { return Ok(out) };
|
||||
if !value.get().starts_with('{') {
|
||||
return Ok(out);
|
||||
}
|
||||
let envs: BTreeMap<String, Box<RawValue>> = serde_json::from_str(value.get()).map_err(invalid)?;
|
||||
for (env, vars) in envs {
|
||||
let Some(vars) = vars.as_object() else { continue };
|
||||
if !vars.get().starts_with('{') {
|
||||
continue;
|
||||
}
|
||||
let vars: BTreeMap<String, Box<RawValue>> = serde_json::from_str(vars.get()).map_err(invalid)?;
|
||||
let mut map = BTreeMap::new();
|
||||
for (k, v) in vars {
|
||||
let s = match v {
|
||||
serde_json::Value::String(s) => s.clone(),
|
||||
serde_json::Value::Number(n) => n.to_string(),
|
||||
serde_json::Value::Bool(b) => b.to_string(),
|
||||
let raw = v.get();
|
||||
let s = match raw.as_bytes().first() {
|
||||
Some(b'"') => serde_json::from_str::<String>(raw).map_err(invalid)?,
|
||||
// Keep numeric tokens: Value::Number would round decimals and large integers.
|
||||
Some(b'-' | b'0'..=b'9' | b't' | b'f') => raw.to_owned(),
|
||||
// Objects configure SSL / auth in IDEA; not variables.
|
||||
_ => continue,
|
||||
};
|
||||
map.insert(k.clone(), s);
|
||||
map.insert(k, s);
|
||||
}
|
||||
out.insert(env.clone(), map);
|
||||
out.insert(env, map);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -304,6 +313,85 @@ impl Resolver<'_> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn preserves_exact_numeric_environment_tokens() {
|
||||
for token in [
|
||||
"0",
|
||||
"-0",
|
||||
"0.0",
|
||||
"-0.000",
|
||||
"1",
|
||||
"-1",
|
||||
"9007199254740991",
|
||||
"9007199254740992",
|
||||
"9007199254740993",
|
||||
"9223372036854775807",
|
||||
"-9223372036854775808",
|
||||
"18446744073709551615",
|
||||
"18446744073709551616",
|
||||
"123456789012345678901234567890",
|
||||
"-123456789012345678901234567890",
|
||||
"1.0000000000000000002",
|
||||
"-1.0000000000000000002",
|
||||
"0.12345678901234567890123456789",
|
||||
"-0.0000000000000000000000000001",
|
||||
"1e-400",
|
||||
"-1e-400",
|
||||
"1e400",
|
||||
"-1e400",
|
||||
"1.2300e+20",
|
||||
"1.2300E+020",
|
||||
] {
|
||||
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
|
||||
let envs = Environments::from_json(&json, "").unwrap();
|
||||
let vars = envs.variables(Some("dev"));
|
||||
assert_eq!(vars["value"], token);
|
||||
let resolver = Resolver { file_vars: &[], env: vars };
|
||||
let mut missing = BTreeSet::new();
|
||||
assert_eq!(resolver.substitute("{\"value\":{{value}}}", &mut missing), format!(r#"{{"value":{token}}}"#));
|
||||
assert!(missing.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_malformed_numeric_environment_tokens() {
|
||||
for token in ["01", "+1", "1.", ".1", "NaN", "Infinity", "--1", "1e", "1e+", "1e--2", "0xFF"] {
|
||||
let json = format!(r#"{{"dev":{{"value":{token}}}}}"#);
|
||||
assert!(Environments::from_json(&json, "").is_err(), "accepted {token}");
|
||||
}
|
||||
assert!(Environments::from_json(r#"{"dev":{"ssl":{"invalid":01}}}"#, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_numeric_tokens_with_whitespace_and_escaped_names() {
|
||||
let envs = Environments::from_json(
|
||||
"\u{feff} \n { \"de\\u0076\": \n {\"va\\u006cue\": \t 1.2300E+020, \"value\": 1.0000000000000000002 }} \n",
|
||||
"",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(envs.names(), vec!["dev"]);
|
||||
assert_eq!(envs.variables(Some("dev"))["value"], "1.0000000000000000002");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_environment_parsing_keeps_types_and_precedence() {
|
||||
let envs = Environments::from_json(
|
||||
r#"{"$shared":{"shared":1.0000000000000000002},"dev":{"v":1,"v":9007199254740993,"enabled":true,"disabled":false,"s":"a\n\"b","null":null,"ssl":{},"arr":[]},"other":42}"#,
|
||||
r#"{"dev":{"v":9223372036854775807}}"#,
|
||||
).unwrap();
|
||||
let vars = envs.variables(Some("dev"));
|
||||
assert_eq!(vars["v"], "9223372036854775807");
|
||||
assert_eq!(vars["shared"], "1.0000000000000000002");
|
||||
assert_eq!(vars["enabled"], "true");
|
||||
assert_eq!(vars["disabled"], "false");
|
||||
assert_eq!(vars["s"], "a\n\"b");
|
||||
assert!(!vars.contains_key("null"));
|
||||
assert!(!vars.contains_key("ssl"));
|
||||
assert!(!vars.contains_key("arr"));
|
||||
assert_eq!(Environments::from_json("[]", "").unwrap().names(), Vec::<String>::new());
|
||||
assert!(Environments::from_json(r#"{"dev":{"v":01}}"#, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_variable_keys_in_every_environment_private_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -249,6 +249,68 @@ mod tests {
|
||||
assert_eq!(url("GET https://api.test/cb?u=http://x\n"), "https://api.test/cb?u=http://x");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sends_exact_environment_numbers_to_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut socket, _) = listener.accept().await.unwrap();
|
||||
let mut request = Vec::new();
|
||||
let (headers, body) = loop {
|
||||
let mut buffer = [0u8; 4096];
|
||||
let read = socket.read(&mut buffer).await.unwrap();
|
||||
assert!(read > 0, "request ended before its body");
|
||||
request.extend_from_slice(&buffer[..read]);
|
||||
let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") else { continue };
|
||||
let headers = String::from_utf8_lossy(&request[..end]);
|
||||
let length: usize = headers
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
let (name, value) = line.split_once(':')?;
|
||||
name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap();
|
||||
if request.len() >= end + 4 + length {
|
||||
break (
|
||||
String::from_utf8(request[..end].to_vec()).unwrap(),
|
||||
String::from_utf8(request[end + 4..end + 4 + length].to_vec()).unwrap(),
|
||||
);
|
||||
}
|
||||
};
|
||||
let response = format!(
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
socket.write_all(response.as_bytes()).await.unwrap();
|
||||
(headers, body)
|
||||
});
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
tmp.path().join(crate::env::PUBLIC_FILE),
|
||||
r#"{"dev":{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let file = tmp.path().join("precision.http");
|
||||
let text = format!(
|
||||
"POST http://{addr}/echo?id={{{{id}}}}\nContent-Type: application/json\nX-Exact-Amount: {{{{amount}}}}\n\n{{\"id\":{{{{id}}}},\"amount\":{{{{amount}}}},\"tiny\":{{{{tiny}}}}}}\n"
|
||||
);
|
||||
let input =
|
||||
crate::RunInput { text: &text, index: 0, file: Some(&file), root: Some(tmp.path()), env: Some("dev") };
|
||||
let exchange =
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), crate::run(input, &CancellationToken::new()))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let expected = r#"{"id":123456789012345678901234567890,"amount":1.0000000000000000002,"tiny":1e-400}"#;
|
||||
let (headers, body) = server.await.unwrap();
|
||||
assert_eq!(headers.lines().next().unwrap(), "POST /echo?id=123456789012345678901234567890 HTTP/1.1");
|
||||
assert!(headers.lines().any(|line| line.eq_ignore_ascii_case("x-exact-amount: 1.0000000000000000002")));
|
||||
assert_eq!(body, expected);
|
||||
assert_eq!(exchange.request.body.as_deref(), Some(expected));
|
||||
assert_eq!(exchange.response.unwrap().body.as_deref(), Some(expected));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn executes_against_a_local_server() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -31,3 +31,7 @@ criterion = { version = "0.5", default-features = false }
|
||||
[[bench]]
|
||||
name = "index"
|
||||
harness = false
|
||||
|
||||
[[bench]]
|
||||
name = "startup"
|
||||
harness = false
|
||||
@@ -0,0 +1,64 @@
|
||||
//! `cargo bench -p ide-index --bench startup`: open a persisted 20k-file
|
||||
//! project and wait for the index to become idle. This is the full warm-start
|
||||
//! path (columnar snapshot, directory/file manifest, lazy redb).
|
||||
|
||||
use criterion::{Criterion, black_box, criterion_group, criterion_main};
|
||||
use ide_core::NullSink;
|
||||
use ide_index::ProjectIndex;
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const FILES: usize = 20_000;
|
||||
|
||||
fn project(root: &Path) {
|
||||
for i in 0..FILES {
|
||||
let dir = root.join(format!("mod{}/src", i % 50));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(format!("File{i}.rs")), format!("fn symbol{i}() {{}}\n")).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
fn bench(c: &mut Criterion) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let cache = tmp.path().join("cache");
|
||||
project(&root);
|
||||
|
||||
let languages = Arc::new(LanguageRegistry::load(None).unwrap());
|
||||
let syntax = Arc::new(Syntax::new());
|
||||
let open = || {
|
||||
ProjectIndex::open(
|
||||
&root,
|
||||
&cache,
|
||||
Vec::<PathBuf>::new(),
|
||||
vec![],
|
||||
languages.clone(),
|
||||
syntax.clone(),
|
||||
Arc::new(NullSink),
|
||||
)
|
||||
.unwrap()
|
||||
};
|
||||
let first = open();
|
||||
assert!(first.wait_idle(Duration::from_secs(30)));
|
||||
drop(first);
|
||||
|
||||
let mut group = c.benchmark_group("startup_20k");
|
||||
group.sample_size(10);
|
||||
group.bench_function("reopen_wait_idle", |b| {
|
||||
b.iter(|| {
|
||||
let index = open();
|
||||
assert!(index.wait_idle(Duration::from_secs(30)));
|
||||
black_box(index.status().files)
|
||||
})
|
||||
});
|
||||
group.finish();
|
||||
}
|
||||
|
||||
criterion_group! {
|
||||
name = benches;
|
||||
config = Criterion::default().measurement_time(Duration::from_secs(3));
|
||||
targets = bench
|
||||
}
|
||||
criterion_main!(benches);
|
||||
@@ -24,6 +24,19 @@ pub(crate) fn char_mask(s: &str) -> u64 {
|
||||
m
|
||||
}
|
||||
|
||||
/// A relative index key must stay below the project root.
|
||||
pub(crate) fn safe_rel(rel: &str) -> bool {
|
||||
let mut any = false;
|
||||
for component in Path::new(rel).components() {
|
||||
if matches!(component, std::path::Component::Normal(_)) {
|
||||
any = true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
any
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub(crate) fn mask_bit(b: u8) -> u64 {
|
||||
match b.to_ascii_lowercase() {
|
||||
@@ -553,6 +566,67 @@ impl IndexData {
|
||||
self.by_path.get(path).copied()
|
||||
}
|
||||
|
||||
/// Reserves the flat tables for a bulk warm-start hydration.
|
||||
pub(crate) fn reserve(&mut self, files: usize, symbols: usize, dirs: usize) {
|
||||
self.files.reserve(files);
|
||||
self.by_path.reserve(files);
|
||||
self.dirs.reserve(dirs);
|
||||
self.hot.rows.reserve(files);
|
||||
self.dirs_hot.rows.reserve(dirs);
|
||||
self.syms.head.reserve(symbols);
|
||||
self.syms.container.reserve(symbols);
|
||||
self.syms.kind.reserve(symbols);
|
||||
self.syms.range.reserve(symbols);
|
||||
}
|
||||
|
||||
/// Hydrates a validated warm-start snapshot in one pass, without per-file
|
||||
/// duplicate checks, ancestor walks or compaction checks.
|
||||
pub(crate) fn load_bulk(
|
||||
&mut self,
|
||||
entries: Vec<(FileEntry, Vec<StoredSymbol>)>,
|
||||
dirs: Vec<(Box<str>, PathBuf, u32)>,
|
||||
) {
|
||||
self.files = Vec::with_capacity(entries.len());
|
||||
self.by_path = HashMap::with_capacity(entries.len());
|
||||
self.hot = HotFiles::default();
|
||||
self.dirs_hot = HotDirs::default();
|
||||
self.syms = SymTable::default();
|
||||
self.dirs = HashMap::with_capacity(dirs.len());
|
||||
let symbols: usize = entries.iter().map(|(_, symbols)| symbols.len()).sum();
|
||||
self.reserve(entries.len(), symbols, dirs.len());
|
||||
self.symbol_count = 0;
|
||||
for (slot, (mut entry, symbols)) in entries.into_iter().enumerate() {
|
||||
let (sym_start, sym_mask) = self.syms.push(&symbols);
|
||||
entry.sym_start = sym_start;
|
||||
entry.sym_len = symbols.len() as u32;
|
||||
self.symbol_count += entry.sym_len;
|
||||
let rel = self.hot.append(&entry.rel);
|
||||
self.hot.set(
|
||||
slot as u32,
|
||||
FileRow {
|
||||
rel,
|
||||
name_start: entry.name_start,
|
||||
sym_start,
|
||||
sym_len: entry.sym_len,
|
||||
sym_mask,
|
||||
rel_mask: entry.rel_mask,
|
||||
name_mask: entry.name_mask,
|
||||
dir_mask: char_mask(entry.dir()),
|
||||
ascii: entry.rel.is_ascii(),
|
||||
live: true,
|
||||
},
|
||||
);
|
||||
self.by_path.insert(entry.path.clone(), slot as u32);
|
||||
self.files.push(Some(entry));
|
||||
}
|
||||
self.file_count = self.files.len() as u32;
|
||||
for (rel, path, count) in dirs {
|
||||
let row = self.dirs_hot.push(&rel, &path);
|
||||
self.dirs.insert(rel, (path, count, row));
|
||||
}
|
||||
self.free.clear();
|
||||
}
|
||||
|
||||
/// The persisted form of a file, rebuilt from [`IndexData::syms`].
|
||||
pub(crate) fn record(&self, e: &FileEntry) -> FileRecord {
|
||||
FileRecord {
|
||||
@@ -793,6 +867,23 @@ mod tests {
|
||||
assert_eq!(d.symbol_count, d.files.iter().flatten().map(|e| e.sym_len).sum::<u32>());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bulk_load_matches_incremental_insert() {
|
||||
let mut incremental = IndexData::default();
|
||||
insert(&mut incremental, "a/b.rs", 2);
|
||||
insert(&mut incremental, "c.rs", 1);
|
||||
assert_consistent(&incremental);
|
||||
|
||||
let mut bulk = IndexData::default();
|
||||
bulk.load_bulk(vec![entry("a/b.rs", 2), entry("c.rs", 1)], vec![("a/".into(), PathBuf::from("/r/a"), 1)]);
|
||||
assert_consistent(&bulk);
|
||||
assert_eq!((bulk.file_count, bulk.symbol_count), (2, 3));
|
||||
assert_eq!(bulk.hot.text(), incremental.hot.text());
|
||||
assert_eq!(bulk.files.len(), incremental.files.len());
|
||||
assert_eq!(bulk.slot(Path::new("/r/a/b.rs")), incremental.slot(Path::new("/r/a/b.rs")));
|
||||
assert_eq!(bulk.dirs["a/"].1, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dirs_follow_inserts_and_removals() {
|
||||
let mut d = IndexData::default();
|
||||
|
||||
@@ -138,6 +138,22 @@ impl PathFilter {
|
||||
&self.config.0 == extra_roots && self.config.1 == excludes
|
||||
}
|
||||
|
||||
/// Stable fingerprint of the persisted-scope configuration; a start-up
|
||||
/// snapshot is only trusted when this still matches.
|
||||
pub(crate) fn stamp(&self) -> String {
|
||||
let mut stamp = format!("include_root={}\n", self.config.0.include_root);
|
||||
for folder in &self.config.0.folders {
|
||||
stamp.push_str(&folder.to_string_lossy());
|
||||
stamp.push('\n');
|
||||
}
|
||||
stamp.push_str("--\n");
|
||||
for exclude in &self.config.1 {
|
||||
stamp.push_str(exclude);
|
||||
stamp.push('\n');
|
||||
}
|
||||
stamp
|
||||
}
|
||||
|
||||
pub(crate) fn reload(&self) -> Self {
|
||||
Self::new(&self.root, &self.config.0, &self.config.1)
|
||||
}
|
||||
@@ -146,6 +162,11 @@ impl PathFilter {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// Whether content roots outside the project root are indexed with it.
|
||||
pub(crate) fn has_extra_roots(&self) -> bool {
|
||||
!self.extra.is_empty()
|
||||
}
|
||||
|
||||
/// The project root, then the extra content roots.
|
||||
pub(crate) fn roots(&self) -> impl Iterator<Item = &Path> {
|
||||
std::iter::once(self.root.as_path())
|
||||
@@ -227,6 +248,30 @@ impl PathFilter {
|
||||
dir.is_some_and(|d| gitignore.matched_path_or_any_parents(d, true).is_ignore())
|
||||
}
|
||||
|
||||
/// All directories at or below `dir` that the index would walk, in walk order.
|
||||
pub(crate) fn dirs(&self, dir: &Path) -> Vec<PathBuf> {
|
||||
let Some((root, _, _)) = self.base_of(dir) else { return Vec::new() };
|
||||
let root = root.to_path_buf();
|
||||
let excludes = self.excludes.clone();
|
||||
let walk_root = root.clone();
|
||||
let walk_excludes = excludes.clone();
|
||||
let walker = WalkBuilder::new(dir)
|
||||
.hidden(false)
|
||||
.parents(true)
|
||||
.ignore(true)
|
||||
.git_ignore(true)
|
||||
.git_global(true)
|
||||
.git_exclude(true)
|
||||
.require_git(ide_core::ignored::inside_git_repo(dir))
|
||||
.follow_links(false)
|
||||
.filter_entry(move |e| {
|
||||
let name = e.file_name().to_string_lossy();
|
||||
!Self::skip_name(&name) && !glob_excluded(&walk_excludes, &walk_root, e.path())
|
||||
})
|
||||
.build();
|
||||
walker.flatten().filter(|e| e.file_type().is_some_and(|t| t.is_dir())).map(|e| e.into_path()).collect()
|
||||
}
|
||||
|
||||
/// All indexable files at or below `dir`, walked in parallel.
|
||||
/// Stops early (returning what was found so far) once `cancel` is set.
|
||||
pub(crate) fn walk(&self, dir: &Path, cancel: &AtomicBool) -> Vec<Found> {
|
||||
|
||||
@@ -1,75 +1,429 @@
|
||||
//! Persistence of file records in redb, so a restart only re-parses changed files.
|
||||
|
||||
use crate::data::FileRecord;
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
||||
use crate::filter::{PathFilter, mtime_of};
|
||||
use ide_core::{Range, SymbolKind};
|
||||
use parking_lot::RwLock;
|
||||
use redb::{Database, Durability, ReadableDatabase, ReadableTable, TableDefinition, TableError};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const FILES: TableDefinition<&str, &[u8]> = TableDefinition::new("files");
|
||||
const META: TableDefinition<&str, &[u8]> = TableDefinition::new("meta");
|
||||
/// Bump when [`FileRecord`] or its encoding changes.
|
||||
const SCHEMA: &str = "openidea-index-3";
|
||||
|
||||
/// The real Git directory for a checkout/worktree (`root/.git` may be a file
|
||||
/// containing `gitdir: ...`).
|
||||
fn git_dir(root: &Path) -> Option<std::path::PathBuf> {
|
||||
let dot_git = root.join(".git");
|
||||
if dot_git.is_dir() {
|
||||
return Some(dot_git);
|
||||
}
|
||||
let text = std::fs::read_to_string(&dot_git).ok()?;
|
||||
let target = text.strip_prefix("gitdir:")?.trim();
|
||||
let path = Path::new(target);
|
||||
Some(if path.is_absolute() { path.to_owned() } else { root.join(path) })
|
||||
}
|
||||
|
||||
/// The config and exclude file of the Git directory `git`; a linked worktree's directory
|
||||
/// (`.git/worktrees/<name>`) reads the shared ones its `commondir` names as well.
|
||||
fn insert_git_inputs(out: &mut std::collections::BTreeSet<std::path::PathBuf>, git: &Path) {
|
||||
out.insert(git.join("config"));
|
||||
out.insert(git.join("info/exclude"));
|
||||
if let Ok(common) = std::fs::read_to_string(git.join("commondir")) {
|
||||
let common = Path::new(common.trim());
|
||||
let common = if common.is_absolute() { common.to_owned() } else { git.join(common) };
|
||||
out.insert(common.join("config"));
|
||||
out.insert(common.join("info/exclude"));
|
||||
}
|
||||
}
|
||||
|
||||
/// External ignore/config inputs, including candidates that do not exist yet.
|
||||
/// These are outside the directory manifest (`.git` is skipped), so the full
|
||||
/// path set must match at warm start.
|
||||
pub(crate) fn external_ignore_candidates(root: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
if let Some(git) = git_dir(root) {
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
out.insert(root.join(".git/config"));
|
||||
out.insert(root.join(".git/info/exclude"));
|
||||
// The walker also reads the ignore files above the root (`parents`), and a project nested
|
||||
// in a repository uses that repository's exclude file and config.
|
||||
for dir in root.ancestors().skip(1) {
|
||||
out.insert(dir.join(".gitignore"));
|
||||
out.insert(dir.join(".ignore"));
|
||||
if let Some(git) = git_dir(dir) {
|
||||
insert_git_inputs(&mut out, &git);
|
||||
}
|
||||
}
|
||||
if let Some(path) = ignore::gitignore::gitconfig_excludes_path() {
|
||||
out.insert(path);
|
||||
}
|
||||
out.insert(std::path::PathBuf::from("/etc/gitconfig"));
|
||||
if let Some(path) = std::env::var_os("GIT_CONFIG_GLOBAL") {
|
||||
out.insert(path.into());
|
||||
}
|
||||
if let Some(home) = std::env::var_os("HOME") {
|
||||
let home = std::path::PathBuf::from(home);
|
||||
out.insert(home.join(".gitconfig"));
|
||||
out.insert(
|
||||
std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.unwrap_or_else(|| home.join(".config"))
|
||||
.join("git/config"),
|
||||
);
|
||||
}
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
/// Existing per-directory ignore files. Adding/removing one changes the
|
||||
/// directory mtime, which the manifest already validates before this list is used.
|
||||
fn per_dir_ignore_candidates(root: &Path, dirs: &[String]) -> Vec<std::path::PathBuf> {
|
||||
let mut out = std::collections::BTreeSet::new();
|
||||
for rel in dirs {
|
||||
let dir = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
|
||||
for name in [".gitignore", ".ignore"] {
|
||||
let path = dir.join(name);
|
||||
if path.is_file() {
|
||||
out.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.into_iter().collect()
|
||||
}
|
||||
|
||||
/// One file's fixed-size columns in the start-up snapshot.
|
||||
#[derive(Clone, Copy, Serialize, Deserialize, Default)]
|
||||
struct FileMeta {
|
||||
mtime: u64,
|
||||
size: u64,
|
||||
hash: [u8; 32],
|
||||
ignored: bool,
|
||||
/// End of this file's symbols in [`Snapshot::symbols`].
|
||||
sym_end: u32,
|
||||
}
|
||||
|
||||
/// One symbol's fixed-size columns; names and containers share [`Snapshot::text`].
|
||||
#[derive(Clone, Copy, Serialize, Deserialize)]
|
||||
struct FlatSymbol {
|
||||
name_start: u32,
|
||||
name_len: u32,
|
||||
container_start: u32,
|
||||
container_len: u32,
|
||||
kind: SymbolKind,
|
||||
range: Range,
|
||||
}
|
||||
|
||||
/// A columnar copy of the index for fast start-up: a handful of contiguous
|
||||
/// arrays and two string arenas instead of one postcard record per redb key.
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
pub(crate) struct Snapshot {
|
||||
rels: String,
|
||||
rel_ends: Vec<u32>,
|
||||
meta: Vec<FileMeta>,
|
||||
symbols: Vec<FlatSymbol>,
|
||||
text: String,
|
||||
dirs: String,
|
||||
dir_ends: Vec<u32>,
|
||||
dir_mtimes: Vec<u64>,
|
||||
dir_counts: Vec<u32>,
|
||||
filter: String,
|
||||
stamp: String,
|
||||
ignore_paths: String,
|
||||
ignore_ends: Vec<u32>,
|
||||
ignore_mtimes: Vec<u64>,
|
||||
ignore_sizes: Vec<u64>,
|
||||
ignore_present: Vec<bool>,
|
||||
ignore_external: Vec<bool>,
|
||||
}
|
||||
|
||||
impl Snapshot {
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.meta.len()
|
||||
}
|
||||
|
||||
pub(crate) fn filter(&self) -> &str {
|
||||
&self.filter
|
||||
}
|
||||
|
||||
pub(crate) fn dirs_len(&self) -> usize {
|
||||
self.dir_mtimes.len()
|
||||
}
|
||||
|
||||
pub(crate) fn dir(&self, dir: usize) -> &str {
|
||||
let start = if dir == 0 { 0 } else { self.dir_ends[dir - 1] as usize };
|
||||
&self.dirs[start..self.dir_ends[dir] as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn dir_mtime(&self, dir: usize) -> u64 {
|
||||
self.dir_mtimes[dir]
|
||||
}
|
||||
|
||||
pub(crate) fn dir_count(&self, dir: usize) -> u32 {
|
||||
self.dir_counts[dir]
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_len(&self) -> usize {
|
||||
self.ignore_mtimes.len()
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_path(&self, i: usize) -> &str {
|
||||
let start = if i == 0 { 0 } else { self.ignore_ends[i - 1] as usize };
|
||||
&self.ignore_paths[start..self.ignore_ends[i] as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_mtime(&self, i: usize) -> u64 {
|
||||
self.ignore_mtimes[i]
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_size(&self, i: usize) -> u64 {
|
||||
self.ignore_sizes[i]
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_present(&self, i: usize) -> bool {
|
||||
self.ignore_present[i]
|
||||
}
|
||||
|
||||
pub(crate) fn ignore_external(&self, i: usize) -> bool {
|
||||
self.ignore_external[i]
|
||||
}
|
||||
|
||||
pub(crate) fn mtime(&self, file: usize) -> u64 {
|
||||
self.meta[file].mtime
|
||||
}
|
||||
|
||||
pub(crate) fn size(&self, file: usize) -> u64 {
|
||||
self.meta[file].size
|
||||
}
|
||||
|
||||
/// Rejects a corrupt snapshot before any offsets are used for slicing.
|
||||
fn valid(&self) -> bool {
|
||||
let max = u32::MAX as usize;
|
||||
if self.rels.len() > max || self.text.len() > max || self.dirs.len() > max || self.ignore_paths.len() > max {
|
||||
return false;
|
||||
}
|
||||
if self.rel_ends.len() != self.meta.len() {
|
||||
return false;
|
||||
}
|
||||
let boundary = |text: &str, end: u32| end as usize <= text.len() && text.is_char_boundary(end as usize);
|
||||
let mut rel_end = 0;
|
||||
for end in &self.rel_ends {
|
||||
if *end < rel_end || !boundary(&self.rels, *end) {
|
||||
return false;
|
||||
}
|
||||
rel_end = *end;
|
||||
}
|
||||
if rel_end as usize != self.rels.len() {
|
||||
return false;
|
||||
}
|
||||
let mut start = 0;
|
||||
for end in &self.rel_ends {
|
||||
let rel = &self.rels[start..*end as usize];
|
||||
if !crate::data::safe_rel(rel) {
|
||||
return false;
|
||||
}
|
||||
start = *end as usize;
|
||||
}
|
||||
let mut sym_end = 0;
|
||||
for meta in &self.meta {
|
||||
if meta.sym_end < sym_end || meta.sym_end as usize > self.symbols.len() {
|
||||
return false;
|
||||
}
|
||||
sym_end = meta.sym_end;
|
||||
}
|
||||
if sym_end as usize != self.symbols.len() {
|
||||
return false;
|
||||
}
|
||||
if !self.symbols.iter().all(|s| {
|
||||
let Some(name_end) = (s.name_start as usize).checked_add(s.name_len as usize) else {
|
||||
return false;
|
||||
};
|
||||
let Some(container_end) = (s.container_start as usize).checked_add(s.container_len as usize) else {
|
||||
return false;
|
||||
};
|
||||
name_end <= self.text.len()
|
||||
&& self.text.is_char_boundary(s.name_start as usize)
|
||||
&& self.text.is_char_boundary(name_end)
|
||||
&& (s.container_start == u32::MAX
|
||||
|| (s.container_start as usize <= self.text.len()
|
||||
&& container_end <= self.text.len()
|
||||
&& self.text.is_char_boundary(s.container_start as usize)
|
||||
&& self.text.is_char_boundary(container_end)))
|
||||
}) {
|
||||
return false;
|
||||
}
|
||||
if self.dir_ends.len() != self.dir_mtimes.len() || self.dir_counts.len() != self.dir_mtimes.len() {
|
||||
return false;
|
||||
}
|
||||
if self.dir_ends.first() != Some(&0) {
|
||||
return false;
|
||||
}
|
||||
let mut dir_end = 0;
|
||||
for end in &self.dir_ends {
|
||||
if *end < dir_end || !boundary(&self.dirs, *end) {
|
||||
return false;
|
||||
}
|
||||
dir_end = *end;
|
||||
}
|
||||
if dir_end as usize != self.dirs.len() {
|
||||
return false;
|
||||
}
|
||||
let mut start = 0;
|
||||
for end in &self.dir_ends {
|
||||
let dir = &self.dirs[start..*end as usize];
|
||||
if !dir.is_empty() && !crate::data::safe_rel(dir) {
|
||||
return false;
|
||||
}
|
||||
start = *end as usize;
|
||||
}
|
||||
if self.ignore_ends.len() != self.ignore_mtimes.len()
|
||||
|| self.ignore_sizes.len() != self.ignore_mtimes.len()
|
||||
|| self.ignore_present.len() != self.ignore_mtimes.len()
|
||||
|| self.ignore_external.len() != self.ignore_mtimes.len()
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let mut ignore_end = 0;
|
||||
for end in &self.ignore_ends {
|
||||
if *end < ignore_end || !boundary(&self.ignore_paths, *end) {
|
||||
return false;
|
||||
}
|
||||
ignore_end = *end;
|
||||
}
|
||||
if ignore_end as usize != self.ignore_paths.len() {
|
||||
return false;
|
||||
}
|
||||
let mut start = 0;
|
||||
for end in &self.ignore_ends {
|
||||
if start == *end as usize {
|
||||
return false;
|
||||
}
|
||||
start = *end as usize;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn rel(&self, file: usize) -> &str {
|
||||
let start = if file == 0 { 0 } else { self.rel_ends[file - 1] as usize };
|
||||
&self.rels[start..self.rel_ends[file] as usize]
|
||||
}
|
||||
|
||||
pub(crate) fn entry(&self, file: usize, root: &Path) -> (FileEntry, Vec<StoredSymbol>) {
|
||||
let meta = self.meta[file];
|
||||
let sym_start = if file == 0 { 0 } else { self.meta[file - 1].sym_end as usize };
|
||||
let symbols = (sym_start..meta.sym_end as usize)
|
||||
.map(|i| {
|
||||
let s = self.symbols[i];
|
||||
let name_start = s.name_start as usize;
|
||||
let name_end = name_start + s.name_len as usize;
|
||||
let name = &self.text[name_start..name_end];
|
||||
let container = (s.container_start != u32::MAX).then(|| {
|
||||
let start = s.container_start as usize;
|
||||
&self.text[start..start + s.container_len as usize]
|
||||
});
|
||||
StoredSymbol {
|
||||
name: name.to_owned(),
|
||||
kind: s.kind,
|
||||
container: container.map(str::to_owned),
|
||||
range: s.range,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let rel = self.rel(file);
|
||||
FileEntry::new(
|
||||
root.join(rel),
|
||||
rel.to_owned(),
|
||||
FileRecord { mtime: meta.mtime, size: meta.size, hash: meta.hash, symbols, ignored: meta.ignored },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Result<T> = std::result::Result<T, String>;
|
||||
|
||||
fn err(e: impl std::fmt::Display) -> String {
|
||||
e.to_string()
|
||||
}
|
||||
|
||||
fn fit(n: usize) -> Result<u32> {
|
||||
u32::try_from(n).map_err(|_| "index snapshot exceeds the 4 GiB span limit".to_owned())
|
||||
}
|
||||
|
||||
pub(crate) struct Store {
|
||||
/// Write-locked only for [`Store::compact`].
|
||||
db: RwLock<Database>,
|
||||
/// Opened lazily: a valid warm-start snapshot serves start-up without
|
||||
/// touching redb. Write-locked only for [`Store::compact`].
|
||||
db: RwLock<Option<Database>>,
|
||||
/// Columnar warm-start snapshot, kept beside the database.
|
||||
snapshot: std::path::PathBuf,
|
||||
/// Recent-file list, readable without opening redb.
|
||||
recent_file: std::path::PathBuf,
|
||||
stamp: String,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// Opens (or creates) `cache_dir/index.redb`. Records written by another
|
||||
/// schema / extractor version or for another root are discarded. Returns
|
||||
/// `None` when the database cannot be used (e.g. locked by another window);
|
||||
/// the index then runs in memory only.
|
||||
/// Opens (or creates) the cache directory. The redb database itself is opened
|
||||
/// lazily so a valid columnar snapshot can serve start-up without touching it.
|
||||
/// Returns `None` only when the cache directory cannot be created; the index
|
||||
/// then runs in memory only.
|
||||
pub(crate) fn open(cache_dir: &Path, root: &Path, fingerprint: &str) -> Option<Self> {
|
||||
if let Err(e) = std::fs::create_dir_all(cache_dir) {
|
||||
tracing::warn!("index cache {}: {e}", cache_dir.display());
|
||||
return None;
|
||||
}
|
||||
let file = cache_dir.join("index.redb");
|
||||
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
|
||||
Some(Self {
|
||||
db: RwLock::new(None),
|
||||
snapshot: cache_dir.join("index.snapshot"),
|
||||
recent_file: cache_dir.join("recent.snapshot"),
|
||||
stamp,
|
||||
})
|
||||
}
|
||||
|
||||
fn open_db(&self) -> Result<Database> {
|
||||
let file = self.snapshot.with_file_name("index.redb");
|
||||
let db = match Database::create(&file) {
|
||||
Ok(db) => db,
|
||||
Err(redb::DatabaseError::DatabaseAlreadyOpen) => {
|
||||
tracing::warn!("index cache {} is in use; indexing in memory only", file.display());
|
||||
return None;
|
||||
return Err(format!("{} is in use", file.display()));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("index cache {} unreadable ({e}); rebuilding", file.display());
|
||||
let _ = std::fs::remove_file(&file);
|
||||
Database::create(&file).map_err(|e| tracing::warn!("index cache: {e}")).ok()?
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
Database::create(&file).map_err(err)?
|
||||
}
|
||||
};
|
||||
let store = Self { db: RwLock::new(db) };
|
||||
let stamp = format!("{SCHEMA}\n{}\n{fingerprint}\n{}", env!("INDEX_SOURCE_HASH"), root.display());
|
||||
match store.meta("stamp") {
|
||||
Ok(Some(v)) if v == stamp.as_bytes() => {}
|
||||
_ => {
|
||||
if let Err(e) = store.reset(&stamp) {
|
||||
tracing::warn!("index cache reset failed: {e}");
|
||||
return None;
|
||||
}
|
||||
let valid = {
|
||||
let txn = db.begin_read().map_err(err)?;
|
||||
match txn.open_table(META) {
|
||||
Ok(table) => table.get("stamp").map_err(err)?.is_some_and(|v| v.value() == self.stamp.as_bytes()),
|
||||
Err(TableError::TableDoesNotExist(_)) => false,
|
||||
Err(e) => return Err(err(e)),
|
||||
}
|
||||
};
|
||||
if !valid {
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
let _ = std::fs::remove_file(&self.recent_file);
|
||||
Self::reset_db(&db, &self.stamp)?;
|
||||
}
|
||||
Some(store)
|
||||
Ok(db)
|
||||
}
|
||||
|
||||
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
|
||||
let txn = self.db.read().begin_read().map_err(err)?;
|
||||
let table = match txn.open_table(META) {
|
||||
Ok(t) => t,
|
||||
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
|
||||
Err(e) => return Err(err(e)),
|
||||
};
|
||||
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
|
||||
fn ensure_db(&self) -> Result<()> {
|
||||
if self.db.read().is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let mut slot = self.db.write();
|
||||
if slot.is_none() {
|
||||
*slot = Some(self.open_db()?);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset(&self, stamp: &str) -> Result<()> {
|
||||
let txn = self.db.read().begin_write().map_err(err)?;
|
||||
fn reset_db(db: &Database, stamp: &str) -> Result<()> {
|
||||
let txn = db.begin_write().map_err(err)?;
|
||||
match txn.delete_table(FILES) {
|
||||
Ok(_) | Err(TableError::TableDoesNotExist(_)) => {}
|
||||
Err(e) => return Err(err(e)),
|
||||
@@ -82,16 +436,31 @@ impl Store {
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
fn meta(&self, key: &str) -> Result<Option<Vec<u8>>> {
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
|
||||
let table = match txn.open_table(META) {
|
||||
Ok(t) => t,
|
||||
Err(TableError::TableDoesNotExist(_)) => return Ok(None),
|
||||
Err(e) => return Err(err(e)),
|
||||
};
|
||||
Ok(table.get(key).map_err(err)?.map(|v| v.value().to_vec()))
|
||||
}
|
||||
|
||||
/// All persisted records; undecodable entries are skipped (and re-indexed).
|
||||
pub(crate) fn load(&self) -> Result<Vec<(String, FileRecord)>> {
|
||||
let txn = self.db.read().begin_read().map_err(err)?;
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let txn = db.as_ref().expect("ensured").begin_read().map_err(err)?;
|
||||
let table = match txn.open_table(FILES) {
|
||||
Ok(t) => t,
|
||||
Err(TableError::TableDoesNotExist(_)) => return Ok(Vec::new()),
|
||||
Err(e) => return Err(err(e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for item in table.iter().map_err(err)? {
|
||||
let iter = table.iter().map_err(err)?;
|
||||
let mut out = Vec::with_capacity(iter.size_hint().0);
|
||||
for item in iter {
|
||||
let (k, v) = item.map_err(err)?;
|
||||
if let Ok(rec) = postcard::from_bytes::<FileRecord>(v.value()) {
|
||||
out.push((k.value().to_owned(), rec));
|
||||
@@ -100,10 +469,115 @@ impl Store {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Writes the current index as one columnar snapshot for fast next start-up.
|
||||
/// Only records persisted below `root` are included; content roots outside it
|
||||
/// are rescanned, as they are for the per-file records.
|
||||
pub(crate) fn save_snapshot(&self, data: &IndexData, filter: &PathFilter, not_after: SystemTime) -> Result<()> {
|
||||
let guard = not_after.duration_since(UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64);
|
||||
let root = filter.root();
|
||||
let mut snap = Snapshot { filter: filter.stamp(), stamp: self.stamp.clone(), ..Default::default() };
|
||||
let mut dirs: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
dirs.insert(String::new());
|
||||
for dir in filter.dirs(root) {
|
||||
if let Ok(rel) = dir.strip_prefix(root) {
|
||||
dirs.insert(rel.to_string_lossy().replace('\\', "/").trim_end_matches('/').to_owned());
|
||||
}
|
||||
}
|
||||
for e in data.files.iter().flatten().filter(|e| e.path.starts_with(root)) {
|
||||
dirs.insert(e.dir().trim_end_matches('/').to_owned());
|
||||
snap.rels.push_str(&e.rel);
|
||||
snap.rel_ends.push(fit(snap.rels.len())?);
|
||||
for s in data.symbols_of(e) {
|
||||
let name_start = fit(snap.text.len())?;
|
||||
snap.text.push_str(s.name);
|
||||
let (container_start, container_len) = match s.container {
|
||||
Some(c) => {
|
||||
let start = fit(snap.text.len())?;
|
||||
snap.text.push_str(c);
|
||||
(start, fit(c.len())?)
|
||||
}
|
||||
None => (u32::MAX, 0),
|
||||
};
|
||||
snap.symbols.push(FlatSymbol {
|
||||
name_start,
|
||||
name_len: fit(s.name.len())?,
|
||||
container_start,
|
||||
container_len,
|
||||
kind: s.kind,
|
||||
range: s.range,
|
||||
});
|
||||
}
|
||||
snap.meta.push(FileMeta {
|
||||
mtime: e.mtime,
|
||||
size: e.size,
|
||||
hash: e.hash,
|
||||
ignored: e.ignored,
|
||||
sym_end: fit(snap.symbols.len())?,
|
||||
});
|
||||
}
|
||||
let dir_list: Vec<String> = dirs.into_iter().collect();
|
||||
for rel in &dir_list {
|
||||
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
|
||||
let mtime = std::fs::symlink_metadata(&path).ok().map(|m| mtime_of(&m)).unwrap_or(0);
|
||||
if mtime > guard {
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
return Ok(());
|
||||
}
|
||||
let count = if rel.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let key = format!("{rel}/");
|
||||
data.dirs.get(key.as_str()).map_or(0, |d| d.1)
|
||||
};
|
||||
snap.dirs.push_str(rel);
|
||||
snap.dir_ends.push(fit(snap.dirs.len())?);
|
||||
snap.dir_mtimes.push(mtime);
|
||||
snap.dir_counts.push(count);
|
||||
}
|
||||
let external: std::collections::BTreeSet<std::path::PathBuf> =
|
||||
external_ignore_candidates(root).into_iter().collect();
|
||||
let mut candidates: std::collections::BTreeSet<std::path::PathBuf> =
|
||||
per_dir_ignore_candidates(root, &dir_list).into_iter().collect();
|
||||
candidates.extend(external.iter().cloned());
|
||||
for path in candidates {
|
||||
let meta = std::fs::metadata(&path).ok();
|
||||
let present = meta.is_some();
|
||||
let mtime = meta.as_ref().map_or(0, mtime_of);
|
||||
if mtime > guard {
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
return Ok(());
|
||||
}
|
||||
snap.ignore_paths.push_str(&path.to_string_lossy());
|
||||
snap.ignore_ends.push(fit(snap.ignore_paths.len())?);
|
||||
snap.ignore_mtimes.push(if present { mtime } else { 0 });
|
||||
snap.ignore_sizes.push(if present { meta.as_ref().map_or(0, |m| m.len()) } else { 0 });
|
||||
snap.ignore_present.push(present);
|
||||
snap.ignore_external.push(external.contains(&path));
|
||||
}
|
||||
fit(snap.rels.len())?;
|
||||
fit(snap.text.len())?;
|
||||
fit(snap.dirs.len())?;
|
||||
fit(snap.ignore_paths.len())?;
|
||||
let bytes = postcard::to_allocvec(&snap).map_err(err)?;
|
||||
let tmp = self.snapshot.with_extension("snapshot.tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(err)?;
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
std::fs::rename(&tmp, &self.snapshot).map_err(err)
|
||||
}
|
||||
|
||||
/// The columnar start-up snapshot, if it was written and still decodes.
|
||||
pub(crate) fn load_snapshot(&self) -> Option<Snapshot> {
|
||||
let bytes = std::fs::read(&self.snapshot).ok()?;
|
||||
let snapshot: Snapshot = postcard::from_bytes(&bytes).ok()?;
|
||||
(snapshot.stamp == self.stamp && snapshot.valid()).then_some(snapshot)
|
||||
}
|
||||
|
||||
/// Applies upserts and deletions in one transaction. Non-durable commits
|
||||
/// are cheap (no fsync) and become durable with the next durable one.
|
||||
pub(crate) fn apply(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) -> Result<()> {
|
||||
let mut txn = self.db.read().begin_write().map_err(err)?;
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let mut txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
if !durable {
|
||||
txn.set_durability(Durability::None).map_err(err)?;
|
||||
}
|
||||
@@ -120,25 +594,47 @@ impl Store {
|
||||
table.remove(rel.as_str()).map_err(err)?;
|
||||
}
|
||||
}
|
||||
// Any file change makes the columnar start-up snapshot stale.
|
||||
let _ = std::fs::remove_file(&self.snapshot);
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
|
||||
/// Reclaims free pages in the database file; `true` if anything was compacted.
|
||||
pub(crate) fn compact(&self) -> Result<bool> {
|
||||
self.db.write().compact().map_err(err)
|
||||
self.ensure_db()?;
|
||||
let mut db = self.db.write();
|
||||
db.as_mut().expect("ensured").compact().map_err(err)
|
||||
}
|
||||
|
||||
pub(crate) fn load_recent(&self) -> Vec<String> {
|
||||
if let Some(recent) = self.load_recent_file() {
|
||||
return recent;
|
||||
}
|
||||
self.meta("recent").ok().flatten().and_then(|v| postcard::from_bytes(&v).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Recent files without opening redb (the warm-start path).
|
||||
pub(crate) fn load_recent_file(&self) -> Option<Vec<String>> {
|
||||
let bytes = std::fs::read(&self.recent_file).ok()?;
|
||||
postcard::from_bytes(&bytes).ok()
|
||||
}
|
||||
|
||||
pub(crate) fn save_recent(&self, recent: &[String]) -> Result<()> {
|
||||
self.save_recent_file(recent)?;
|
||||
let bytes = postcard::to_allocvec(recent).map_err(err)?;
|
||||
self.set_meta("recent", &bytes)
|
||||
}
|
||||
|
||||
/// Rewrites only the recent sidecar, without opening redb.
|
||||
pub(crate) fn save_recent_file(&self, recent: &[String]) -> Result<()> {
|
||||
let bytes = postcard::to_allocvec(recent).map_err(err)?;
|
||||
std::fs::write(&self.recent_file, &bytes).map_err(err)
|
||||
}
|
||||
|
||||
fn set_meta(&self, key: &str, value: &[u8]) -> Result<()> {
|
||||
let txn = self.db.read().begin_write().map_err(err)?;
|
||||
self.ensure_db()?;
|
||||
let db = self.db.read();
|
||||
let txn = db.as_ref().expect("ensured").begin_write().map_err(err)?;
|
||||
txn.open_table(META).map_err(err)?.insert(key, value).map_err(err)?;
|
||||
txn.commit().map_err(err)
|
||||
}
|
||||
@@ -183,4 +679,58 @@ mod tests {
|
||||
let s = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
|
||||
assert!(s.load().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_roundtrips_and_is_invalidated_by_changes() {
|
||||
use crate::data::{FileEntry, IndexData, StoredSymbol};
|
||||
use ide_core::{Range, SymbolKind};
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store = Store::open(dir.path(), Path::new("/p"), "fp").unwrap();
|
||||
let record = FileRecord {
|
||||
mtime: 7,
|
||||
size: 3,
|
||||
symbols: vec![StoredSymbol {
|
||||
name: "OrderController".into(),
|
||||
kind: SymbolKind::Class,
|
||||
container: Some("Order".into()),
|
||||
range: Range::default(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let mut data = IndexData::default();
|
||||
let (entry, symbols) =
|
||||
FileEntry::new(std::path::PathBuf::from("/p/src/Order.rs"), "src/Order.rs".into(), record);
|
||||
data.insert(entry, symbols);
|
||||
// Content roots outside the project root are rescanned, not snapshotted.
|
||||
let (entry, symbols) =
|
||||
FileEntry::new(std::path::PathBuf::from("/elsewhere/x.rs"), "x.rs".into(), FileRecord::default());
|
||||
data.insert(entry, symbols);
|
||||
store
|
||||
.save_snapshot(&data, &PathFilter::new(Path::new("/p"), &Vec::new().into(), &[]), SystemTime::now())
|
||||
.unwrap();
|
||||
|
||||
let snapshot = store.load_snapshot().expect("snapshot");
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
assert_eq!(snapshot.rel(0), "src/Order.rs");
|
||||
let (entry, symbols) = snapshot.entry(0, Path::new("/p"));
|
||||
assert_eq!(&*entry.rel, "src/Order.rs");
|
||||
assert_eq!(symbols.len(), 1);
|
||||
assert_eq!(symbols[0].name, "OrderController");
|
||||
assert_eq!(symbols[0].container.as_deref(), Some("Order"));
|
||||
|
||||
store.apply(&[], &[], false).unwrap();
|
||||
assert!(store.load_snapshot().is_none(), "a file change invalidates the snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_validation_rejects_bad_paths_and_split_utf8() {
|
||||
let escaping =
|
||||
Snapshot { rels: "../x".into(), rel_ends: vec![4], meta: vec![FileMeta::default()], ..Default::default() };
|
||||
assert!(!escaping.valid());
|
||||
|
||||
let split =
|
||||
Snapshot { rels: "é".into(), rel_ends: vec![1], meta: vec![FileMeta::default()], ..Default::default() };
|
||||
assert!(!split.valid());
|
||||
}
|
||||
}
|
||||
@@ -772,6 +772,95 @@ fn persisted_symbols_do_not_load_through_escaping_symlink_ancestors() {
|
||||
assert_eq!(initial.lock()[0], 0, "unsafe cached symbols must never be published");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incremental_changes_refresh_the_warm_start_snapshot() {
|
||||
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
|
||||
let idx = f.open();
|
||||
let changed = f.write("src/a.rs", "fn renamed() {}\n");
|
||||
idx.update(&[changed], &[]);
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
assert!(f.cache.join("index.snapshot").exists(), "a watcher update must regenerate the snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_changed_ignore_rules() {
|
||||
let f = Fixture::new(&[("sub/generated/a.rs", "fn a() {}\n"), (".git/HEAD", "ref: refs/heads/main\n")]);
|
||||
f.write("sub/.gitignore", "");
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2, "generated/a.rs plus .gitignore itself");
|
||||
drop(idx);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
f.write("sub/.gitignore", "generated/\n");
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "an updated ignore rule invalidates the warm snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_changed_ignore_rules_above_the_project() {
|
||||
// The project is a folder of a repository whose ignore files sit above it.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let repo = f.root.parent().unwrap().to_owned();
|
||||
std::fs::create_dir_all(repo.join(".git/info")).unwrap();
|
||||
std::fs::write(repo.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(repo.join(".gitignore"), "proj/generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "a new ignore file above the project invalidates the warm snapshot");
|
||||
drop(idx);
|
||||
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
std::fs::write(repo.join(".gitignore"), "").unwrap();
|
||||
std::fs::write(repo.join(".git/info/exclude"), "proj/src/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "so do the enclosing repository's changed rules");
|
||||
assert!(!symbols(&idx, "a", SymbolFilter::All).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_a_changed_exclude_file_of_a_linked_worktree() {
|
||||
// The project is a linked worktree: its `.git` file points into the main repository,
|
||||
// whose shared `info/exclude` the walker reads through `commondir`.
|
||||
let f = Fixture::new(&[("generated/a.rs", "fn a() {}\n"), ("src/b.rs", "fn b() {}\n")]);
|
||||
let main = f.root.parent().unwrap().join("main/.git");
|
||||
let worktree = main.join("worktrees/proj");
|
||||
std::fs::create_dir_all(main.join("info")).unwrap();
|
||||
std::fs::create_dir_all(&worktree).unwrap();
|
||||
std::fs::write(main.join("HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
std::fs::write(worktree.join("HEAD"), "ref: refs/heads/wt\n").unwrap();
|
||||
std::fs::write(worktree.join("commondir"), "../..\n").unwrap();
|
||||
std::fs::write(f.root.join(".git"), format!("gitdir: {}\n", worktree.display())).unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
drop(idx);
|
||||
|
||||
std::fs::write(main.join("info/exclude"), "generated/\n").unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1, "the shared exclude file invalidates the warm snapshot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_detects_a_new_file_in_an_existing_empty_directory() {
|
||||
let f = Fixture::new(&[("src/a.rs", "fn a() {}\n")]);
|
||||
std::fs::create_dir(f.root.join("empty")).unwrap();
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 1);
|
||||
assert!(idx.find_dirs("empty", 10).is_empty());
|
||||
drop(idx);
|
||||
let idx = f.open();
|
||||
assert!(idx.find_dirs("empty", 10).is_empty(), "empty folders are not searchable after warm start");
|
||||
drop(idx);
|
||||
|
||||
f.write("empty/new.rs", "fn new_symbol() {}\n");
|
||||
let idx = f.open();
|
||||
assert_eq!(idx.status().files, 2);
|
||||
assert_eq!(symbols(&idx, "new_symbol", SymbolFilter::All), ["new_symbol"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rule_changes_refresh_only_the_affected_index_subtree() {
|
||||
for (rule, subtree) in [(".git/info/exclude", ""), ("sub/.gitignore", "sub"), ("sub/.ignore", "sub")] {
|
||||
@@ -834,3 +923,42 @@ fn repository_rule_changes_refresh_members_of_an_unindexed_container() {
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(files(&idx, "item.txt"), ["member/generated/item.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warm_start_sees_edits_deletions_and_renames_made_while_closed() {
|
||||
let f = Fixture::new(&[
|
||||
("src/a.rs", "fn aaaa() {}\n"),
|
||||
("src/deep/b.rs", "fn bbbb() {}\n"),
|
||||
("c.rs", "fn cccc() {}\n"),
|
||||
]);
|
||||
let idx = f.open();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
drop(idx);
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
// Warm start of an unchanged tree keeps everything.
|
||||
let idx = f.open();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(symbols(&idx, "aaaa", SymbolFilter::All), ["aaaa"]);
|
||||
drop(idx);
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
// Same size, new content; a deletion; a new file two levels down; a rename.
|
||||
f.write("src/a.rs", "fn zzzz() {}\n");
|
||||
std::fs::remove_file(f.root.join("c.rs")).unwrap();
|
||||
f.write("src/deep/deeper/n.rs", "fn nnnn() {}\n");
|
||||
std::fs::rename(f.root.join("src/deep/b.rs"), f.root.join("src/deep/r.rs")).unwrap();
|
||||
let idx = f.open();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(symbols(&idx, "aaaa", SymbolFilter::All).is_empty(), "stale content");
|
||||
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
|
||||
assert!(symbols(&idx, "cccc", SymbolFilter::All).is_empty(), "deleted file");
|
||||
assert_eq!(symbols(&idx, "nnnn", SymbolFilter::All), ["nnnn"]);
|
||||
assert_eq!(symbols(&idx, "bbbb", SymbolFilter::All), ["bbbb"]);
|
||||
assert_eq!(idx.status().files, 3);
|
||||
drop(idx);
|
||||
// After the cold rescan, the next warm start agrees.
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let idx = f.open();
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert_eq!(idx.status().files, 3);
|
||||
assert_eq!(symbols(&idx, "zzzz", SymbolFilter::All), ["zzzz"]);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The background indexing thread: initial load + full scan, then incremental
|
||||
//! updates queued by the watcher.
|
||||
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol};
|
||||
use crate::filter::{Found, PathFilter};
|
||||
use crate::data::{FileEntry, FileRecord, IndexData, StoredSymbol, safe_rel};
|
||||
use crate::filter::{Found, PathFilter, mtime_of};
|
||||
use crate::store::Store;
|
||||
use ide_core::{IdeEvent, SharedSink, TaskId};
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
@@ -12,12 +12,16 @@ use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
/// Files larger than this are listed but not parsed for symbols.
|
||||
pub const MAX_SYMBOL_FILE: u64 = 2 * 1024 * 1024;
|
||||
const CHUNK: usize = 512;
|
||||
const PROGRESS_EVERY: Duration = Duration::from_millis(250);
|
||||
#[cfg(not(test))]
|
||||
const SNAPSHOT_DELAY: Duration = Duration::from_secs(2);
|
||||
#[cfg(test)]
|
||||
const SNAPSHOT_DELAY: Duration = Duration::from_millis(50);
|
||||
pub(crate) const MAX_RECENT: usize = 50;
|
||||
|
||||
pub(crate) enum Job {
|
||||
@@ -55,6 +59,11 @@ pub(crate) struct Shared {
|
||||
queue: Mutex<Queue>,
|
||||
wake: Condvar,
|
||||
idle: Condvar,
|
||||
/// Coalesced post-update snapshot refresh.
|
||||
snapshot_due: Mutex<Option<Instant>>,
|
||||
/// Directories whose watcher events still need reconciliation before a
|
||||
/// refreshed snapshot can be trusted.
|
||||
pending_dirs: Mutex<HashSet<PathBuf>>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -79,6 +88,8 @@ impl Shared {
|
||||
queue: Mutex::default(),
|
||||
wake: Condvar::new(),
|
||||
idle: Condvar::new(),
|
||||
snapshot_due: Mutex::default(),
|
||||
pending_dirs: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,8 +146,15 @@ impl Shared {
|
||||
q.busy = true;
|
||||
}
|
||||
if scan {
|
||||
self.load_persisted();
|
||||
self.full_scan();
|
||||
// A warm start can trust the snapshot when neither the scope nor any
|
||||
// directory/file metadata changed. Rebuilding the walk's file list is
|
||||
// the expensive part; validate the flat manifest first.
|
||||
if !self.try_fast_start() {
|
||||
let snapshot_guard = SystemTime::now();
|
||||
let found = self.walk_roots(None);
|
||||
self.load_persisted(&found);
|
||||
self.full_scan(Some(found), snapshot_guard);
|
||||
}
|
||||
} else {
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
}
|
||||
@@ -147,17 +165,38 @@ impl Shared {
|
||||
self.idle.notify_all();
|
||||
}
|
||||
loop {
|
||||
let mut save_due = false;
|
||||
let jobs: Vec<Job> = {
|
||||
let mut q = self.queue.lock();
|
||||
while q.jobs.is_empty() && !self.is_cancelled() {
|
||||
self.wake.wait(&mut q);
|
||||
loop {
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if !q.jobs.is_empty() {
|
||||
break;
|
||||
}
|
||||
let due = *self.snapshot_due.lock();
|
||||
if let Some(due) = due {
|
||||
if Instant::now() >= due {
|
||||
save_due = true;
|
||||
break;
|
||||
}
|
||||
self.wake.wait_until(&mut q, due);
|
||||
} else {
|
||||
self.wake.wait(&mut q);
|
||||
}
|
||||
}
|
||||
if self.is_cancelled() {
|
||||
return;
|
||||
if save_due {
|
||||
Vec::new()
|
||||
} else {
|
||||
q.busy = true;
|
||||
q.jobs.drain(..).collect()
|
||||
}
|
||||
q.busy = true;
|
||||
q.jobs.drain(..).collect()
|
||||
};
|
||||
if save_due {
|
||||
self.save_snapshot_now();
|
||||
continue;
|
||||
}
|
||||
self.process(jobs);
|
||||
let mut q = self.queue.lock();
|
||||
q.busy = false;
|
||||
@@ -167,27 +206,114 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
fn load_persisted(&self) {
|
||||
let Some(store) = &self.store else { return };
|
||||
/// Warm start: validate the snapshot's flat directory/file manifest and hydrate
|
||||
/// without walking the tree. A changed scope, symlink, directory mtime, file
|
||||
/// mtime or file size falls back to the full walk.
|
||||
fn try_fast_start(&self) -> bool {
|
||||
let started = Instant::now();
|
||||
let Some(store) = &self.store else { return false };
|
||||
let load_started = Instant::now();
|
||||
let Some(snapshot) = store.load_snapshot() else { return false };
|
||||
let loaded = load_started.elapsed();
|
||||
let filter = self.filter();
|
||||
let root = filter.root().to_owned();
|
||||
match store.load() {
|
||||
Ok(records) => {
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| {
|
||||
let path = root.join(rel);
|
||||
!filter.is_excluded(&path, false) && dunce::canonicalize(&path).is_ok_and(|real| real == path)
|
||||
})
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
if filter.has_extra_roots() || snapshot.filter() != filter.stamp() {
|
||||
return false;
|
||||
}
|
||||
let root = filter.root();
|
||||
for i in 0..snapshot.dirs_len() {
|
||||
let rel = snapshot.dir(i);
|
||||
let path = if rel.is_empty() { root.to_owned() } else { root.join(rel) };
|
||||
let Ok(meta) = std::fs::symlink_metadata(&path) else { return false };
|
||||
if !meta.is_dir() || mtime_of(&meta) != snapshot.dir_mtime(i) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let external = crate::store::external_ignore_candidates(root);
|
||||
let saved_external: Vec<&str> = (0..snapshot.ignore_len())
|
||||
.filter(|&i| snapshot.ignore_external(i))
|
||||
.map(|i| snapshot.ignore_path(i))
|
||||
.collect();
|
||||
if external.len() != saved_external.len()
|
||||
|| external.iter().zip(&saved_external).any(|(path, saved)| path.to_string_lossy() != **saved)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
for i in 0..snapshot.ignore_len() {
|
||||
let path = Path::new(snapshot.ignore_path(i));
|
||||
let meta = std::fs::metadata(path).ok();
|
||||
let present = meta.is_some();
|
||||
if present != snapshot.ignore_present(i) {
|
||||
return false;
|
||||
}
|
||||
if let Some(meta) = meta
|
||||
&& (mtime_of(&meta) != snapshot.ignore_mtime(i) || meta.len() != snapshot.ignore_size(i))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let build_started = Instant::now();
|
||||
let entries: Vec<Option<(FileEntry, Vec<StoredSymbol>)>> = (0..snapshot.len())
|
||||
.into_par_iter()
|
||||
.map(|i| {
|
||||
let path = root.join(snapshot.rel(i));
|
||||
let meta = std::fs::symlink_metadata(&path).ok()?;
|
||||
if !meta.is_file() || mtime_of(&meta) != snapshot.mtime(i) || meta.len() != snapshot.size(i) {
|
||||
return None;
|
||||
}
|
||||
Some(snapshot.entry(i, root))
|
||||
})
|
||||
.collect();
|
||||
let built = build_started.elapsed();
|
||||
if entries.iter().any(Option::is_none) {
|
||||
return false;
|
||||
}
|
||||
let validated = entries;
|
||||
let mut entries = Vec::with_capacity(validated.len());
|
||||
for entry in validated {
|
||||
entries.push(entry.expect("validated above"));
|
||||
}
|
||||
let dirs: Vec<(Box<str>, PathBuf, u32)> = (0..snapshot.dirs_len())
|
||||
.filter_map(|i| {
|
||||
let rel = snapshot.dir(i);
|
||||
(!rel.is_empty() && snapshot.dir_count(i) > 0).then(|| {
|
||||
let key = format!("{rel}/");
|
||||
(key.into_boxed_str(), root.join(rel), snapshot.dir_count(i))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let insert_started = Instant::now();
|
||||
let mut d = self.data.write();
|
||||
d.load_bulk(entries, dirs);
|
||||
drop(d);
|
||||
tracing::info!(
|
||||
"index snapshot: warm start in {:?} (load {loaded:?}, validate/build {built:?}, insert {:?})",
|
||||
started.elapsed(),
|
||||
insert_started.elapsed()
|
||||
);
|
||||
#[cfg(test)]
|
||||
eprintln!(
|
||||
"index snapshot: warm start load {loaded:?}, validate/build {built:?}, insert {:?}, total {:?}",
|
||||
insert_started.elapsed(),
|
||||
started.elapsed()
|
||||
);
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
self.emit_state();
|
||||
let recent = store.load_recent_file().unwrap_or_else(|| {
|
||||
let recent = store.load_recent();
|
||||
let _ = store.save_recent_file(&recent);
|
||||
recent
|
||||
});
|
||||
let mut r = self.recent.lock();
|
||||
for p in recent.into_iter().map(|rel| root.join(rel)) {
|
||||
if !r.contains(&p) {
|
||||
r.push(p);
|
||||
}
|
||||
}
|
||||
r.truncate(MAX_RECENT);
|
||||
true
|
||||
}
|
||||
|
||||
fn load_recent(&self, store: &Store, root: &Path) {
|
||||
let recent: Vec<PathBuf> = store.load_recent().into_iter().map(|rel| root.join(rel)).collect();
|
||||
let mut r = self.recent.lock();
|
||||
for p in recent {
|
||||
@@ -198,6 +324,72 @@ impl Shared {
|
||||
r.truncate(MAX_RECENT);
|
||||
}
|
||||
|
||||
fn load_persisted(&self, found: &[Found]) {
|
||||
let Some(store) = &self.store else { return };
|
||||
let root = self.filter().root().to_owned();
|
||||
let seen: HashSet<&Path> = found.iter().map(|f| f.path.as_path()).collect();
|
||||
let started = Instant::now();
|
||||
match store.load_snapshot() {
|
||||
Some(snapshot) => {
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = (0..snapshot.len())
|
||||
.into_par_iter()
|
||||
.filter_map(|i| {
|
||||
let rel = snapshot.rel(i);
|
||||
if !safe_rel(rel) {
|
||||
return None;
|
||||
}
|
||||
let path = root.join(rel);
|
||||
seen.contains(path.as_path()).then(|| snapshot.entry(i, &root))
|
||||
})
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
|
||||
d.reserve(entries.len(), symbols, 0);
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
tracing::info!(
|
||||
"index snapshot: hydrated {} files, {} symbols in {:?}",
|
||||
d.file_count,
|
||||
d.symbol_count,
|
||||
started.elapsed()
|
||||
);
|
||||
#[cfg(test)]
|
||||
eprintln!("index snapshot: hydrated {} files in {:?}", d.file_count, started.elapsed());
|
||||
}
|
||||
None => match store.load() {
|
||||
Ok(records) => {
|
||||
// Fallback for an index that changed since the snapshot: redb
|
||||
// records are decoded one file at a time and filtered by the walk.
|
||||
let entries: Vec<(FileEntry, Vec<StoredSymbol>)> = records
|
||||
.into_par_iter()
|
||||
.filter(|(rel, _)| {
|
||||
safe_rel(rel) && {
|
||||
let path = root.join(rel);
|
||||
seen.contains(path.as_path())
|
||||
}
|
||||
})
|
||||
.map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec))
|
||||
.collect();
|
||||
let mut d = self.data.write();
|
||||
let symbols = entries.iter().map(|(_, symbols)| symbols.len()).sum();
|
||||
d.reserve(entries.len(), symbols, 0);
|
||||
for (e, symbols) in entries {
|
||||
d.insert(e, symbols);
|
||||
}
|
||||
tracing::info!(
|
||||
"index cache: hydrated {} files, {} symbols in {:?}",
|
||||
d.file_count,
|
||||
d.symbol_count,
|
||||
started.elapsed()
|
||||
);
|
||||
}
|
||||
Err(e) => tracing::warn!("index cache load failed: {e}"),
|
||||
},
|
||||
}
|
||||
self.load_recent(store, &root);
|
||||
}
|
||||
|
||||
/// A cancelled scan (project closed) still ends its progress task and the indexing state.
|
||||
fn end_scan(&self, task: TaskId) {
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
@@ -212,11 +404,24 @@ impl Shared {
|
||||
self.emit_state();
|
||||
}
|
||||
|
||||
fn full_scan(&self) {
|
||||
self.scan(None);
|
||||
fn walk_roots(&self, dirs: Option<&[PathBuf]>) -> Vec<Found> {
|
||||
let filter = self.filter();
|
||||
let roots: Vec<&Path> = match dirs {
|
||||
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
|
||||
None => filter.roots().collect(),
|
||||
};
|
||||
let mut found = Vec::new();
|
||||
for root in roots {
|
||||
found.extend(filter.walk(root, &self.cancelled));
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn scan(&self, dirs: Option<&[PathBuf]>) {
|
||||
fn full_scan(&self, found: Option<Vec<Found>>, guard: SystemTime) {
|
||||
self.scan(None, found, guard);
|
||||
}
|
||||
|
||||
fn scan(&self, dirs: Option<&[PathBuf]>, prewalked: Option<Vec<Found>>, guard: SystemTime) {
|
||||
let started = Instant::now();
|
||||
self.indexing.store(true, Ordering::SeqCst);
|
||||
self.emit_state();
|
||||
@@ -231,14 +436,7 @@ impl Shared {
|
||||
});
|
||||
|
||||
let filter = self.filter();
|
||||
let mut found = Vec::new();
|
||||
let roots: Vec<&Path> = match dirs {
|
||||
Some(dirs) => dirs.iter().map(PathBuf::as_path).collect(),
|
||||
None => filter.roots().collect(),
|
||||
};
|
||||
for root in roots {
|
||||
found.extend(filter.walk(root, &self.cancelled));
|
||||
}
|
||||
let found = prewalked.unwrap_or_else(|| self.walk_roots(dirs));
|
||||
if self.is_cancelled() {
|
||||
return self.end_scan(task);
|
||||
}
|
||||
@@ -285,6 +483,17 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
self.persist(&[], &[], true);
|
||||
if let Some(store) = &self.store {
|
||||
let recent: Vec<String> = self.recent.lock().iter().filter_map(|p| filter.store_key(p)).collect();
|
||||
let d = self.data.read();
|
||||
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
|
||||
tracing::warn!("index snapshot: {e}");
|
||||
}
|
||||
if let Err(e) = store.save_recent_file(&recent) {
|
||||
tracing::warn!("index recent snapshot: {e}");
|
||||
}
|
||||
}
|
||||
*self.snapshot_due.lock() = None;
|
||||
self.indexing.store(false, Ordering::SeqCst);
|
||||
self.sink.emit(IdeEvent::Task {
|
||||
id: task,
|
||||
@@ -347,6 +556,36 @@ impl Shared {
|
||||
self.persist(&puts, &[], false);
|
||||
}
|
||||
|
||||
fn schedule_snapshot(&self) {
|
||||
if self.store.is_none() {
|
||||
return;
|
||||
}
|
||||
let mut due = self.snapshot_due.lock();
|
||||
if due.is_none() {
|
||||
*due = Some(Instant::now() + SNAPSHOT_DELAY);
|
||||
}
|
||||
}
|
||||
|
||||
fn save_snapshot_now(&self) {
|
||||
let dirs: Vec<PathBuf> = self.pending_dirs.lock().drain().collect();
|
||||
if !dirs.is_empty() {
|
||||
// Reconcile the directories touched since the last scan before
|
||||
// recording their mtimes; otherwise a missed watcher event could be
|
||||
// blessed into the manifest.
|
||||
self.scan(Some(&dirs), None, SystemTime::now());
|
||||
return;
|
||||
}
|
||||
if let Some(store) = &self.store {
|
||||
let filter = self.filter();
|
||||
let guard = SystemTime::now();
|
||||
let d = self.data.read();
|
||||
if let Err(e) = store.save_snapshot(&d, &filter, guard) {
|
||||
tracing::warn!("index snapshot: {e}");
|
||||
}
|
||||
}
|
||||
*self.snapshot_due.lock() = None;
|
||||
}
|
||||
|
||||
fn persist(&self, puts: &[(String, FileRecord)], deletes: &[String], durable: bool) {
|
||||
if let Some(store) = &self.store
|
||||
&& let Err(e) = store.apply(puts, deletes, durable)
|
||||
@@ -406,6 +645,17 @@ impl Shared {
|
||||
Job::Reparse(before) => reparse.push(before),
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut pending = self.pending_dirs.lock();
|
||||
for path in changed.iter().chain(removed.iter()) {
|
||||
let dir = if path.is_dir() {
|
||||
path.clone()
|
||||
} else {
|
||||
path.parent().map_or_else(|| path.clone(), Path::to_owned)
|
||||
};
|
||||
pending.insert(dir);
|
||||
}
|
||||
}
|
||||
if !rule_dirs.is_empty() {
|
||||
{
|
||||
let mut filter = self.filter.write();
|
||||
@@ -420,14 +670,14 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
if !rescan {
|
||||
self.scan(Some(&roots));
|
||||
self.scan(Some(&roots), None, SystemTime::now());
|
||||
changed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
|
||||
removed.retain(|p| !roots.iter().any(|root| p.starts_with(root)));
|
||||
}
|
||||
}
|
||||
if rescan {
|
||||
// The scan also picks up whatever `changed` / `removed` reported.
|
||||
self.full_scan();
|
||||
self.full_scan(None, SystemTime::now());
|
||||
changed.clear();
|
||||
removed.clear();
|
||||
}
|
||||
@@ -522,6 +772,7 @@ impl Shared {
|
||||
if touched {
|
||||
self.persist(&[], &[], true);
|
||||
self.emit_state();
|
||||
self.schedule_snapshot();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,3 +792,17 @@ enum Extracted {
|
||||
Unchanged,
|
||||
New(FileRecord),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::data::safe_rel;
|
||||
|
||||
#[test]
|
||||
fn cache_keys_stay_below_the_root() {
|
||||
assert!(safe_rel("src/main.rs"));
|
||||
assert!(safe_rel("a/b/c.rs"));
|
||||
for bad in ["", "/etc/passwd", "../out.rs", "a/../../b.rs", "./a.rs"] {
|
||||
assert!(!safe_rel(bad), "{bad:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,10 @@ pub trait Handler: Send + Sync + 'static {
|
||||
|
||||
/// Called once the response to a `method` request is queued: what it sends follows the response.
|
||||
fn after_response(&self, _method: &str) {}
|
||||
|
||||
/// Adjusts every message from the peer (responses included) before it is dispatched, e.g.
|
||||
/// to repair values the peer spells more loosely than the types that read them accept.
|
||||
fn normalize_incoming(&self, _message: &mut Value) {}
|
||||
}
|
||||
|
||||
/// A [`Handler`] that ignores notifications and rejects requests.
|
||||
@@ -585,9 +589,10 @@ impl Peer {
|
||||
.expect("spawn jsonrpc notification thread");
|
||||
}
|
||||
|
||||
// Reader: parses and dispatches; never runs handler code itself.
|
||||
// Reader: parses and dispatches; runs no handler code beyond `normalize_incoming`.
|
||||
{
|
||||
let inner = inner.clone();
|
||||
let handler = handler.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(reader);
|
||||
let seen_message = AtomicBool::new(false);
|
||||
@@ -610,7 +615,7 @@ impl Peer {
|
||||
match msg {
|
||||
Ok(Some(body)) => {
|
||||
seen_message.store(true, Ordering::Relaxed);
|
||||
dispatch(&inner, ¬if_tx, &body);
|
||||
dispatch(&inner, &*handler, ¬if_tx, &body);
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
@@ -783,15 +788,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]) {
|
||||
fn dispatch(inner: &Arc<Inner>, handler: &dyn Handler, 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;
|
||||
}
|
||||
};
|
||||
handler.normalize_incoming(&mut msg);
|
||||
let Value::Object(mut obj) = msg else {
|
||||
tracing::warn!("jsonrpc: ignoring non-object message");
|
||||
return;
|
||||
|
||||
@@ -541,3 +541,29 @@ async fn output_before_the_handshake_is_reported_not_dropped() {
|
||||
assert!(lines.iter().any(|l| l == "python: not found"), "{lines:?}");
|
||||
assert!(lines.iter().any(|l| l.ends_with(". . .") && l.len() > 6), "{lines:?}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn incoming_messages_are_normalized_before_dispatch() {
|
||||
struct Server;
|
||||
impl Handler for Server {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
Ok(params)
|
||||
}
|
||||
}
|
||||
struct Client;
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, _: &str, _: Value) {}
|
||||
fn on_request(&self, _: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((codes::METHOD_NOT_FOUND, "no".into()))
|
||||
}
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
if let Some(result) = message.get_mut("result") {
|
||||
*result = json!("normalized");
|
||||
}
|
||||
}
|
||||
}
|
||||
let (client, _server) = pair(Arc::new(Client), Arc::new(Server));
|
||||
let v: Value = client.request("echo", "raw", None).await.unwrap();
|
||||
assert_eq!(v, json!("normalized"));
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const BUILTIN: &[(&str, &str)] = &[
|
||||
("groovy", include_str!("../../../languages/groovy.toml")),
|
||||
("haskell", include_str!("../../../languages/haskell.toml")),
|
||||
("hcl", include_str!("../../../languages/hcl.toml")),
|
||||
("helm-template", include_str!("../../../languages/helm-template.toml")),
|
||||
("html", include_str!("../../../languages/html.toml")),
|
||||
("http", include_str!("../../../languages/http.toml")),
|
||||
("ignore", include_str!("../../../languages/ignore.toml")),
|
||||
@@ -548,6 +549,7 @@ mod tests {
|
||||
"Groovy",
|
||||
"Haskell",
|
||||
"Haxe",
|
||||
"Helm Template",
|
||||
"HXML",
|
||||
"HTTP",
|
||||
"IDL",
|
||||
@@ -841,6 +843,30 @@ mod tests {
|
||||
assert!(reg.is_framework_marker(&root.join("src/manage.py")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helm_charts_make_yaml_and_notes_in_templates_folders_helm_templates() {
|
||||
let reg = reg();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let detect = |file: &str| reg.detect(&root.join(file), None).as_str().to_owned();
|
||||
std::fs::create_dir_all(root.join("charts/podinfo/templates/tests")).unwrap();
|
||||
std::fs::create_dir_all(root.join("k8s/templates")).unwrap();
|
||||
std::fs::write(root.join("charts/podinfo/Chart.yaml"), "apiVersion: v2\nname: podinfo\n").unwrap();
|
||||
assert_eq!(detect("charts/podinfo/Chart.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/values.yaml"), "yaml");
|
||||
assert_eq!(detect("charts/podinfo/templates/deployment.yaml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/tests/connection.yml"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/_helpers.tpl"), "helm-template");
|
||||
assert_eq!(detect("charts/podinfo/templates/NOTES.txt"), "helm-template");
|
||||
// No `Chart.yaml` / `Chart.yml` above this `templates` folder.
|
||||
assert_eq!(detect("k8s/templates/deployment.yaml"), "yaml");
|
||||
assert_eq!(detect("common.gotmpl"), "helm-template");
|
||||
// Outside a chart, `.tpl` belongs to other template engines (Smarty, Terraform).
|
||||
assert_ne!(detect("site/index.tpl"), "helm-template");
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yaml")));
|
||||
assert!(reg.is_framework_marker(&root.join("charts/podinfo/Chart.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_django_fixture_has_templates_and_a_page() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -218,6 +218,37 @@ impl ClientHandler {
|
||||
true
|
||||
}
|
||||
|
||||
/// Ends every running task of a tsserver that died at its heap limit: the process
|
||||
/// behind them is gone, so no `end` will ever arrive. Without this the tasks sit
|
||||
/// as zombies until [`PROGRESS_STALE`] and the server stays "loading", holding
|
||||
/// empty answers back for [`LOADING_WAIT`] — while vtsls already restarts
|
||||
/// tsserver, which re-reports its own loading.
|
||||
fn tsserver_died(&self, mgr: &LspManager) {
|
||||
let events: Vec<IdeEvent> = {
|
||||
let mut progress = self.server.progress.lock();
|
||||
progress
|
||||
.drain()
|
||||
.map(|(_, p)| IdeEvent::Task {
|
||||
id: p.task,
|
||||
title: p.title,
|
||||
message: p.message,
|
||||
fraction: Some(1.0),
|
||||
done: true,
|
||||
cancellable: false,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let ended = !events.is_empty();
|
||||
for event in events {
|
||||
self.server.emit(mgr.sink(), event);
|
||||
}
|
||||
if ended {
|
||||
self.server.refresh_busy_status(mgr.sink());
|
||||
self.server.progress_ended();
|
||||
}
|
||||
self.server.set_loading(mgr.sink(), false);
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
@@ -391,6 +422,10 @@ fn tsserver_ran_out_of_memory(text: &str) -> bool {
|
||||
}
|
||||
|
||||
impl Handler for ClientHandler {
|
||||
fn normalize_incoming(&self, message: &mut Value) {
|
||||
crate::uri::encode_loose_file_uris(message);
|
||||
}
|
||||
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
let Some(mgr) = self.mgr.upgrade() else { return };
|
||||
match method {
|
||||
@@ -406,6 +441,10 @@ impl Handler for ClientHandler {
|
||||
let text = params.get("message").and_then(Value::as_str).unwrap_or_default();
|
||||
if self.server.config.tsserver_project && tsserver_ran_out_of_memory(text) {
|
||||
tracing::warn!("[{}] {text}", self.server.name);
|
||||
// The tasks of the dead tsserver never see their `end`: end them now
|
||||
// instead of letting them sit as zombies until the stale window, and
|
||||
// stop holding queries back — the restarted instance re-reports.
|
||||
self.tsserver_died(&mgr);
|
||||
if mgr.first_message(&self.server.name, TSSERVER_OUT_OF_MEMORY) {
|
||||
mgr.sink().emit(IdeEvent::Notify {
|
||||
level: NotifyLevel::Error,
|
||||
@@ -563,7 +602,12 @@ fn merge(base: &mut Value, layer: Value) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::tsserver_ran_out_of_memory;
|
||||
use super::*;
|
||||
use crate::server::Progress;
|
||||
use ide_core::event::SharedSink;
|
||||
use ide_core::{LanguageId, TaskId};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
#[test]
|
||||
fn tsserver_aborts_are_out_of_memory() {
|
||||
@@ -575,4 +619,85 @@ mod tests {
|
||||
assert!(!tsserver_ran_out_of_memory("Code: 134."));
|
||||
assert!(tsserver_ran_out_of_memory("[error] TSServer exited. Code: 134. Signal: null\n"));
|
||||
}
|
||||
|
||||
/// A vtsls server with two running tasks, one marked stalled, and `loading` set:
|
||||
/// the state a tsserver OOM leaves behind.
|
||||
fn loaded_vtsls(events: Arc<Mutex<Vec<IdeEvent>>>) -> (Arc<LspManager>, Arc<crate::server::Server>) {
|
||||
let sink: SharedSink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |event: IdeEvent| events.lock().unwrap().push(event))
|
||||
};
|
||||
let languages = Arc::new(ide_language::LanguageRegistry::from_configs(vec![]));
|
||||
let mgr = LspManager::new(PathBuf::from("/p"), languages, sink);
|
||||
let cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str("name = \"vtsls\"\ncommand = \"vtsls\"\ntsserver-project = true").unwrap();
|
||||
let server = Arc::new(crate::server::Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue")));
|
||||
server.progress.lock().insert(
|
||||
"loading".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Initializing 'tsconfig.json'".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.1),
|
||||
stalled: false,
|
||||
},
|
||||
);
|
||||
server.progress.lock().insert(
|
||||
"stuck".into(),
|
||||
Progress {
|
||||
task: TaskId::next(),
|
||||
title: "Analyzing 'index.vue' and its dependencies".into(),
|
||||
message: None,
|
||||
cancel: None,
|
||||
last: tokio::time::Instant::now(),
|
||||
fraction: Some(0.2),
|
||||
stalled: true,
|
||||
},
|
||||
);
|
||||
let quiet: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
server.set_loading(&quiet, true);
|
||||
(mgr, server)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_ends_every_task_and_the_loading_state() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
// Both tasks ended (done: true), including the stalled one; none left running.
|
||||
let tasks: Vec<_> = events
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
IdeEvent::Task { title, done, .. } => Some((title.clone(), *done)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(tasks.len(), 2, "{tasks:?}");
|
||||
assert!(tasks.iter().all(|(_, done)| *done), "{tasks:?}");
|
||||
assert!(server.progress.lock().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
assert!(!server.is_busy());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tsserver_dying_without_tasks_only_clears_loading() {
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let (mgr, server) = loaded_vtsls(events.clone());
|
||||
server.progress.lock().clear();
|
||||
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), 0);
|
||||
|
||||
handler.tsserver_died(&mgr);
|
||||
|
||||
assert!(events.lock().unwrap().is_empty());
|
||||
assert!(!server.is_loading());
|
||||
// A status event may be published, but no task events.
|
||||
assert!(server.progress.lock().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -482,7 +482,8 @@ mod tests {
|
||||
std::fs::create_dir_all(output.join("test001")).unwrap();
|
||||
// As jdtls writes it: the class's source file name in the path, the library's
|
||||
// classpath attributes after the folder.
|
||||
let escaped = output.to_string_lossy().replace('/', "\\/");
|
||||
// JDT writes the folder as a portable path, `/` on Windows too, each `/` escaped.
|
||||
let escaped = output.to_string_lossy().replace('\\', "/").replace('/', "\\/");
|
||||
let handle = format!("=demo/{escaped}=/ai.openidea.kotlin=/true=/<test001(Db.class");
|
||||
let uri = format!("jdt://contents/main-0/test001/Db.kt?{}", utf8_percent_encode(&handle, PATH));
|
||||
assert_eq!(folder_class(&uri), Some(("test001".into(), "Db".into())));
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! `file://` URI ⇄ path conversion (RFC 8089, VS Code-compatible encoding).
|
||||
|
||||
use percent_encoding::{AsciiSet, CONTROLS, percent_decode_str, utf8_percent_encode};
|
||||
use serde_json::Value;
|
||||
use std::borrow::Cow;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
|
||||
@@ -22,6 +24,68 @@ const PATH: &AsciiSet = &CONTROLS
|
||||
.add(b'|')
|
||||
.add(b'}');
|
||||
|
||||
/// What RFC 3986 doesn't allow in a URI and a server may still leave unencoded in a `file:`
|
||||
/// URI (non-ASCII is always encoded); `%` stays, it starts the escapes already there.
|
||||
const LOOSE: &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'}');
|
||||
|
||||
/// Fields of LSP messages whose value is a URI.
|
||||
const URI_FIELDS: [&str; 9] =
|
||||
["uri", "targetUri", "newUri", "oldUri", "baseUri", "scopeUri", "rootUri", "target", "href"];
|
||||
|
||||
/// Percent-encodes the characters URIs don't allow in the `file:` URIs of `value`: the URI
|
||||
/// fields and the keys of a workspace edit's `changes`. Eclipse's jdtls sends a path as is
|
||||
/// (`file:///H:/项目/…`), which `lsp_types::Uri` rejects, failing the whole response. Other
|
||||
/// strings stay as sent: an edit's `newText` may well start with `file:` (YAML, `.env`).
|
||||
pub fn encode_loose_file_uris(value: &mut Value) {
|
||||
match value {
|
||||
Value::Array(items) => items.iter_mut().for_each(encode_loose_file_uris),
|
||||
Value::Object(map) => {
|
||||
for (key, value) in map.iter_mut() {
|
||||
match value {
|
||||
Value::String(s) if URI_FIELDS.contains(&key.as_str()) => {
|
||||
if let Some(encoded) = encode_loose(s) {
|
||||
*s = encoded;
|
||||
}
|
||||
}
|
||||
Value::Object(changes) if key == "changes" => {
|
||||
if changes.keys().any(|k| encode_loose(k).is_some()) {
|
||||
*changes = std::mem::take(changes)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (encode_loose(&k).unwrap_or(k), v))
|
||||
.collect();
|
||||
}
|
||||
changes.values_mut().for_each(encode_loose_file_uris);
|
||||
}
|
||||
_ => encode_loose_file_uris(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn encode_loose(s: &str) -> Option<String> {
|
||||
if !s.get(..5).is_some_and(|scheme| scheme.eq_ignore_ascii_case("file:")) {
|
||||
return None;
|
||||
}
|
||||
match Cow::from(utf8_percent_encode(s, LOOSE)) {
|
||||
Cow::Owned(encoded) => Some(encoded),
|
||||
Cow::Borrowed(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_to_uri_string(path: &Path) -> String {
|
||||
let s = if cfg!(windows) {
|
||||
dunce::simplified(path).to_string_lossy().replace('\\', "/")
|
||||
@@ -95,6 +159,47 @@ pub use ide_core::text::is_virtual_uri;
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn file_uris_a_server_leaves_unencoded_are_encoded() {
|
||||
// jdtls on a project under a Chinese folder (`java.usages` / references response).
|
||||
let mut v = serde_json::json!({ "result": [{
|
||||
"location": { "uri": "file:///H:/项目/online disk/A.java", "range": { "start": { "line": 1, "character": 2 }, "end": { "line": 1, "character": 3 } } },
|
||||
"kind": "unknown"
|
||||
}], "edit": { "changes": { "file:///tmp/目录/b.java": [] } }, "text": "项目" });
|
||||
let raw = v["result"][0]["location"].clone();
|
||||
assert!(serde_json::from_value::<lsp_types::Location>(raw).is_err(), "lsp-types rejects it as sent");
|
||||
encode_loose_file_uris(&mut v);
|
||||
let uri = v["result"][0]["location"]["uri"].as_str().unwrap();
|
||||
assert_eq!(uri, "file:///H:/%E9%A1%B9%E7%9B%AE/online%20disk/A.java");
|
||||
let location: lsp_types::Location = serde_json::from_value(v["result"][0]["location"].clone()).unwrap();
|
||||
assert!(uri_to_path(location.uri.as_str()).unwrap().ends_with("项目/online disk/A.java"));
|
||||
assert!(v["edit"]["changes"].get("file:///tmp/%E7%9B%AE%E5%BD%95/b.java").is_some());
|
||||
assert_eq!(v["text"], "项目", "only file: URIs change");
|
||||
// Already encoded: untouched.
|
||||
let mut encoded = serde_json::json!({ "uri": "file:///a%20b/%E9%A1%B9.java" });
|
||||
encode_loose_file_uris(&mut encoded);
|
||||
assert_eq!(encoded["uri"], "file:///a%20b/%E9%A1%B9.java");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_that_starts_with_file_is_not_a_uri() {
|
||||
// A YAML formatting edit and a completion whose text begins with `file:`.
|
||||
let mut v = serde_json::json!({ "result": [
|
||||
{ "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 1, "character": 0 } }, "newText": "file: a b\n - c\n" },
|
||||
{ "label": "file:", "insertText": "file: ${1:path}", "documentation": "file: the input" }
|
||||
], "edit": { "changes": { "file:///tmp/a b.yaml": [{ "newText": "file: x y" }] } } });
|
||||
encode_loose_file_uris(&mut v);
|
||||
assert_eq!(v["result"][0]["newText"], "file: a b\n - c\n");
|
||||
assert_eq!(v["result"][1]["insertText"], "file: ${1:path}");
|
||||
assert_eq!(v["result"][1]["documentation"], "file: the input");
|
||||
let changes = &v["edit"]["changes"]["file:///tmp/a%20b.yaml"];
|
||||
assert_eq!(changes[0]["newText"], "file: x y");
|
||||
// A diagnostic's help link is a URI too.
|
||||
let mut d = serde_json::json!({ "codeDescription": { "href": "file:///tmp/help files/rule.html" } });
|
||||
encode_loose_file_uris(&mut d);
|
||||
assert_eq!(d["codeDescription"]["href"], "file:///tmp/help%20files/rule.html");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The discovery file, relative to the app data directory.
|
||||
@@ -39,6 +40,9 @@ pub fn kept_token(path: &Path) -> io::Result<String> {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Discovery {
|
||||
/// Where a client on this computer connects (the file of an IDE without this field: `127.0.0.1`).
|
||||
#[serde(default = "loopback")]
|
||||
pub address: IpAddr,
|
||||
pub port: u16,
|
||||
pub token: String,
|
||||
/// The IDE's process.
|
||||
@@ -47,6 +51,10 @@ pub struct Discovery {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
fn loopback() -> IpAddr {
|
||||
IpAddr::V4(Ipv4Addr::LOCALHOST)
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
pub fn read(path: &Path) -> io::Result<Self> {
|
||||
let bytes = std::fs::read(path)?;
|
||||
@@ -103,7 +111,13 @@ mod tests {
|
||||
fn round_trips_privately_and_only_its_owner_removes_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let path = discovery_path(tmp.path());
|
||||
let ours = Discovery { port: 1234, token: "t".into(), pid: std::process::id(), version: "0.1.0".into() };
|
||||
let ours = Discovery {
|
||||
address: "100.92.72.95".parse().unwrap(),
|
||||
port: 1234,
|
||||
token: "t".into(),
|
||||
pid: std::process::id(),
|
||||
version: "0.1.0".into(),
|
||||
};
|
||||
ours.write(&path).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap(), ours);
|
||||
#[cfg(unix)]
|
||||
@@ -118,6 +132,10 @@ mod tests {
|
||||
ours.write(&path).unwrap();
|
||||
Discovery::remove_if_ours(&path);
|
||||
assert!(!path.exists());
|
||||
|
||||
// The file of an IDE that listened on 127.0.0.1 only has no address.
|
||||
std::fs::write(&path, r#"{"port":1,"token":"t","pid":1,"version":"0.1.0"}"#).unwrap();
|
||||
assert_eq!(Discovery::read(&path).unwrap().address, loopback());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
//! the IDE's built-in tools.
|
||||
//!
|
||||
//! - **Transport:** MCP Streamable HTTP (2025-06-18; 2025-03-26 and 2024-11-05
|
||||
//! clients too) on `127.0.0.1:<random port>`, endpoint `/mcp`. Responses are
|
||||
//! clients too) on `127.0.0.1:<random port>` (or another address of
|
||||
//! [`ServerOptions`]), endpoint `/mcp`. Responses are
|
||||
//! plain JSON (no SSE streams: `GET` is 405). Every request carries
|
||||
//! `Authorization: Bearer <token>` (random, per run); requests from another
|
||||
//! `Origin` or `Host` are refused (DNS rebinding).
|
||||
//! - **Projects:** the `X-OpenIDEA-Project` header or the `?project=` query
|
||||
//! names a folder; the server picks the open project containing it.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`port`, `token`, `pid`,
|
||||
//! `version`; readable only by the user) while the server runs.
|
||||
//! - **Discovery:** `<app-data>/mcp/server.json` (`address`, `port`, `token`,
|
||||
//! `pid`, `version`; readable only by the user) while the server runs.
|
||||
//! - **Stdio:** `<app exe> --mcp-stdio [--project <path>]` reads the discovery
|
||||
//! file and relays newline-delimited JSON-RPC to the server ([`stdio`]), so
|
||||
//! persisted client configurations hold no token.
|
||||
@@ -23,7 +24,7 @@ mod server;
|
||||
pub mod stdio;
|
||||
|
||||
pub use discovery::{DISCOVERY_FILE, Discovery, TOKEN_FILE, discovery_path, kept_token, token_path};
|
||||
pub use server::{McpServer, ServerOptions};
|
||||
pub use server::{McpServer, ServerOptions, local_address};
|
||||
pub use tokio_util::sync::CancellationToken;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -85,9 +86,15 @@ pub trait ToolRegistry: Send + Sync + 'static {
|
||||
) -> Result<String, ToolCallError>;
|
||||
}
|
||||
|
||||
/// The URL of the server at `port` for `project`.
|
||||
/// The URL of the server at `127.0.0.1:<port>` for `project`.
|
||||
pub fn endpoint_url(port: u16, project: &Path) -> String {
|
||||
format!("http://127.0.0.1:{port}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
endpoint_url_at(std::net::Ipv4Addr::LOCALHOST.into(), port, project)
|
||||
}
|
||||
|
||||
/// The URL of the server at `<address>:<port>` for `project`.
|
||||
pub fn endpoint_url_at(address: std::net::IpAddr, port: u16, project: &Path) -> String {
|
||||
let at = std::net::SocketAddr::new(address, port);
|
||||
format!("http://{at}/mcp?project={}", encode_query(&project.to_string_lossy()))
|
||||
}
|
||||
|
||||
/// The stdio bridge's command line for `project`: `exe --mcp-stdio --project <project>`.
|
||||
@@ -133,6 +140,9 @@ mod tests {
|
||||
fn urls_and_commands_name_the_project() {
|
||||
let url = endpoint_url(4321, Path::new("/home/me/my app/项目"));
|
||||
assert_eq!(url, "http://127.0.0.1:4321/mcp?project=/home/me/my%20app/%E9%A1%B9%E7%9B%AE");
|
||||
let ip = |s: &str| s.parse::<std::net::IpAddr>().unwrap();
|
||||
assert_eq!(endpoint_url_at(ip("100.92.72.95"), 80, Path::new("/p")), "http://100.92.72.95:80/mcp?project=/p");
|
||||
assert_eq!(endpoint_url_at(ip("fd7a::1"), 80, Path::new("/p")), "http://[fd7a::1]:80/mcp?project=/p");
|
||||
assert_eq!(
|
||||
stdio_command(Path::new("/Applications/OpenIDEA.app/Contents/MacOS/openidea"), Path::new("/p")),
|
||||
["/Applications/OpenIDEA.app/Contents/MacOS/openidea", "--mcp-stdio", "--project", "/p"]
|
||||
|
||||
@@ -9,6 +9,7 @@ use hyper::header::{self, HeaderMap, HeaderValue};
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use serde_json::{Value, json};
|
||||
use std::convert::Infallible;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
@@ -40,6 +41,8 @@ type Body = Either<Full<Bytes>, EventStream>;
|
||||
struct Shared {
|
||||
registry: Arc<dyn ToolRegistry>,
|
||||
token: String,
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
requests: protocol::Requests,
|
||||
cancel: CancellationToken,
|
||||
@@ -48,6 +51,7 @@ struct Shared {
|
||||
/// The running server. Stopping it (or dropping it) closes the port and
|
||||
/// removes its discovery file.
|
||||
pub struct McpServer {
|
||||
address: IpAddr,
|
||||
port: u16,
|
||||
/// Why the port asked for in [`ServerOptions::port`] was not used.
|
||||
port_unavailable: Option<String>,
|
||||
@@ -60,7 +64,10 @@ pub struct McpServer {
|
||||
/// How [`McpServer::start_with`] listens.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ServerOptions {
|
||||
/// Listen on `127.0.0.1:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// The address to listen on; `None`: `127.0.0.1`, this computer only. Another address of
|
||||
/// this computer, or `0.0.0.0` / `::` for every address, lets other computers connect.
|
||||
pub address: Option<IpAddr>,
|
||||
/// Listen on `<address>:<port>`. When it can't be bound (in use, or reserved by the
|
||||
/// system), a free port is taken instead and [`McpServer::port_unavailable`] says why.
|
||||
/// `None`: a free port.
|
||||
pub port: Option<u16>,
|
||||
@@ -86,15 +93,20 @@ impl McpServer {
|
||||
runtime: &tokio::runtime::Handle,
|
||||
options: ServerOptions,
|
||||
) -> std::io::Result<Self> {
|
||||
let address = options.address.unwrap_or(IpAddr::V4(Ipv4Addr::LOCALHOST));
|
||||
let bind = |port: u16| {
|
||||
std::net::TcpListener::bind(SocketAddr::new(address, port))
|
||||
.map_err(|e| std::io::Error::new(e.kind(), format!("{}: {e}", SocketAddr::new(address, port))))
|
||||
};
|
||||
let (listener, port_unavailable) = match options.port {
|
||||
Some(port) => match std::net::TcpListener::bind(("127.0.0.1", port)) {
|
||||
Some(port) => match bind(port) {
|
||||
Ok(listener) => (listener, None),
|
||||
Err(e) => {
|
||||
tracing::warn!("MCP server: port {port} unavailable ({e}); taking a free one");
|
||||
(std::net::TcpListener::bind(("127.0.0.1", 0))?, Some(format!("port {port}: {e}")))
|
||||
(bind(0)?, Some(format!("port {port}: {e}")))
|
||||
}
|
||||
},
|
||||
None => (std::net::TcpListener::bind(("127.0.0.1", 0))?, None),
|
||||
None => (bind(0)?, None),
|
||||
};
|
||||
listener.set_nonblocking(true)?;
|
||||
let port = listener.local_addr()?.port();
|
||||
@@ -108,6 +120,7 @@ impl McpServer {
|
||||
};
|
||||
if let Some(path) = &discovery {
|
||||
let info = Discovery {
|
||||
address: local_address(address),
|
||||
port,
|
||||
token: token.clone(),
|
||||
pid: std::process::id(),
|
||||
@@ -120,13 +133,19 @@ impl McpServer {
|
||||
let shared = Arc::new(Shared {
|
||||
registry,
|
||||
token: token.clone(),
|
||||
address,
|
||||
port,
|
||||
requests: Default::default(),
|
||||
cancel: cancel.clone(),
|
||||
});
|
||||
runtime.spawn(accept(listener, shared, stopped));
|
||||
tracing::info!("MCP server on 127.0.0.1:{port}");
|
||||
Ok(Self { port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
tracing::info!("MCP server on {}", SocketAddr::new(address, port));
|
||||
Ok(Self { address, port, port_unavailable, token, discovery, shutdown, cancel })
|
||||
}
|
||||
|
||||
/// The address listened on (`0.0.0.0` / `::`: every address).
|
||||
pub fn address(&self) -> IpAddr {
|
||||
self.address
|
||||
}
|
||||
|
||||
pub fn port(&self) -> u16 {
|
||||
@@ -143,14 +162,24 @@ impl McpServer {
|
||||
&self.token
|
||||
}
|
||||
|
||||
/// The endpoint URL for `project`.
|
||||
/// The endpoint URL for `project`, at the address a client on this computer connects to.
|
||||
pub fn url(&self, project: &Path) -> String {
|
||||
crate::endpoint_url(self.port, project)
|
||||
crate::endpoint_url_at(local_address(self.address), self.port, project)
|
||||
}
|
||||
|
||||
pub fn stop(self) {}
|
||||
}
|
||||
|
||||
/// The address a client on this computer connects to for a server listening on `address`:
|
||||
/// loopback for every address (`0.0.0.0` / `::`), else `address` itself.
|
||||
pub fn local_address(address: IpAddr) -> IpAddr {
|
||||
match address {
|
||||
IpAddr::V4(a) if a.is_unspecified() => IpAddr::V4(Ipv4Addr::LOCALHOST),
|
||||
IpAddr::V6(a) if a.is_unspecified() => IpAddr::V6(Ipv6Addr::LOCALHOST),
|
||||
a => a,
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for McpServer {
|
||||
fn drop(&mut self) {
|
||||
self.cancel.cancel();
|
||||
@@ -212,7 +241,7 @@ async fn respond(req: Request<Incoming>, shared: &Arc<Shared>, scope: String) ->
|
||||
if req.uri().path() != "/mcp" {
|
||||
return plain(StatusCode::NOT_FOUND, "not found: the endpoint is /mcp");
|
||||
}
|
||||
if !local_request(req.headers(), shared.port) {
|
||||
if !local_request(req.headers(), shared.address, shared.port) {
|
||||
return plain(StatusCode::FORBIDDEN, "requests from web pages are refused");
|
||||
}
|
||||
if !authorized(req.headers(), &shared.token) {
|
||||
@@ -343,11 +372,16 @@ impl hyper::body::Body for EventStream {
|
||||
}
|
||||
}
|
||||
|
||||
/// No `Origin` other than this server's own, and a loopback `Host`: a web
|
||||
/// page (DNS rebinding) can't reach the tools even before the token check.
|
||||
fn local_request(headers: &HeaderMap, port: u16) -> bool {
|
||||
/// No `Origin` other than this server's own, and a `Host` naming this server by IP address
|
||||
/// (loopback, the address listened on, or any when listening on every address) or as
|
||||
/// `localhost`: never another host name, so a web page (DNS rebinding) can't reach the tools
|
||||
/// even before the token check.
|
||||
fn local_request(headers: &HeaderMap, address: IpAddr, port: u16) -> bool {
|
||||
let ours = |authority: &str| {
|
||||
["127.0.0.1", "localhost", "[::1]"].iter().any(|host| authority.eq_ignore_ascii_case(&format!("{host}:{port}")))
|
||||
authority.eq_ignore_ascii_case(&format!("localhost:{port}"))
|
||||
|| authority.parse::<SocketAddr>().is_ok_and(|a| {
|
||||
a.port() == port && (a.ip().is_loopback() || a.ip() == address || address.is_unspecified())
|
||||
})
|
||||
};
|
||||
let host_ok = headers.get(header::HOST).is_none_or(|h| h.to_str().is_ok_and(ours));
|
||||
let origin_ok = headers.get(header::ORIGIN).is_none_or(|o| {
|
||||
@@ -534,7 +568,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_loopback_hosts_and_origins_of_this_server_pass() {
|
||||
fn only_hosts_and_origins_of_this_server_pass() {
|
||||
let headers = |pairs: &[(header::HeaderName, &str)]| {
|
||||
let mut h = HeaderMap::new();
|
||||
for (k, v) in pairs {
|
||||
@@ -542,16 +576,43 @@ mod tests {
|
||||
}
|
||||
h
|
||||
};
|
||||
assert!(local_request(&headers(&[]), 80));
|
||||
assert!(local_request(&headers(&[(header::HOST, "127.0.0.1:80")]), 80));
|
||||
let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
|
||||
let host = |h: &str, address: IpAddr| local_request(&headers(&[(header::HOST, h)]), address, 80);
|
||||
assert!(local_request(&headers(&[]), loopback, 80));
|
||||
assert!(host("127.0.0.1:80", loopback));
|
||||
assert!(host("[::1]:80", loopback));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "localhost:80"), (header::ORIGIN, "http://localhost:80")]),
|
||||
loopback,
|
||||
80
|
||||
));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "evil.example:80")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::HOST, "127.0.0.1:81")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), 80));
|
||||
assert!(!host("evil.example:80", loopback));
|
||||
assert!(!host("127.0.0.1:81", loopback));
|
||||
assert!(!host("100.92.72.95:80", loopback), "another address of a loopback server");
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "https://evil.example")]), loopback, 80));
|
||||
assert!(!local_request(&headers(&[(header::ORIGIN, "null")]), loopback, 80));
|
||||
|
||||
// Listening on one address: that address too, still never a host name.
|
||||
let lan: IpAddr = "100.92.72.95".parse().unwrap();
|
||||
assert!(host("100.92.72.95:80", lan) && host("127.0.0.1:80", lan));
|
||||
assert!(!host("100.92.72.96:80", lan) && !host("evil.example:80", lan));
|
||||
// Listening on every address: any IP address, still never a host name.
|
||||
let every: IpAddr = "0.0.0.0".parse().unwrap();
|
||||
assert!(host("192.168.1.7:80", every) && host("[fe80::1]:80", every));
|
||||
assert!(!host("evil.example:80", every) && !host("192.168.1.7:81", every));
|
||||
assert!(local_request(
|
||||
&headers(&[(header::HOST, "192.168.1.7:80"), (header::ORIGIN, "http://192.168.1.7:80")]),
|
||||
every,
|
||||
80
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clients_on_this_computer_connect_to_loopback_for_every_address() {
|
||||
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
|
||||
assert_eq!(local_address(ip("0.0.0.0")), ip("127.0.0.1"));
|
||||
assert_eq!(local_address(ip("::")), ip("::1"));
|
||||
assert_eq!(local_address(ip("100.92.72.95")), ip("100.92.72.95"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -148,13 +148,14 @@ async fn post(
|
||||
)
|
||||
};
|
||||
let info = Discovery::read(discovery).map_err(|_| not_running())?;
|
||||
let stream = tokio::net::TcpStream::connect(("127.0.0.1", info.port)).await.map_err(|_| not_running())?;
|
||||
let at = std::net::SocketAddr::new(info.address, info.port);
|
||||
let stream = tokio::net::TcpStream::connect(at).await.map_err(|_| not_running())?;
|
||||
let (mut sender, conn) = hyper::client::conn::http1::handshake(hyper_util::rt::TokioIo::new(stream))
|
||||
.await
|
||||
.map_err(|e| format!("cannot reach OpenIDEA: {e}"))?;
|
||||
tokio::spawn(conn);
|
||||
let request = Request::post(format!("/mcp?project={}", crate::encode_query(&project.to_string_lossy())))
|
||||
.header(header::HOST, format!("127.0.0.1:{}", info.port))
|
||||
.header(header::HOST, at.to_string())
|
||||
.header(header::AUTHORIZATION, format!("Bearer {}", info.token))
|
||||
.header("mcp-session-id", scope)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
|
||||
@@ -373,7 +373,7 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
s.server.take().unwrap().stop();
|
||||
let free = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
|
||||
let token = "ab".repeat(32);
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()) };
|
||||
let options = ServerOptions { port: Some(free), token: Some(token.clone()), ..Default::default() };
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
s.server =
|
||||
@@ -393,3 +393,34 @@ async fn a_fixed_port_and_token_are_used_and_a_taken_port_falls_back_to_a_free_o
|
||||
second.port_unavailable()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn another_listen_address_is_used_and_one_not_on_this_computer_fails() {
|
||||
let mut s = setup();
|
||||
s.server.take().unwrap().stop();
|
||||
let registry = Arc::new(Registry { project: s.project.clone() });
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
|
||||
// Every address: clients here use loopback; other computers name the server by an address of it.
|
||||
let every = ServerOptions { address: Some("0.0.0.0".parse().unwrap()), ..Default::default() };
|
||||
s.server = Some(McpServer::start_with(registry.clone(), Some(s.discovery.clone()), &runtime, every).unwrap());
|
||||
let port = s.server().port();
|
||||
assert_eq!(s.server().address().to_string(), "0.0.0.0");
|
||||
assert!(s.server().url(&s.project).starts_with(&format!("http://127.0.0.1:{port}/mcp?")));
|
||||
assert_eq!(Discovery::read(&s.discovery).unwrap().address.to_string(), "127.0.0.1");
|
||||
let ping = json!({ "jsonrpc": "2.0", "id": 1, "method": "ping" }).to_string();
|
||||
let request = |host: String| {
|
||||
format!(
|
||||
"POST /mcp HTTP/1.1\r\nHost: {host}\r\n{}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{ping}",
|
||||
bearer(&s),
|
||||
ping.len()
|
||||
)
|
||||
};
|
||||
assert_eq!(http(port, &request(format!("192.0.2.7:{port}"))).await.0, 200);
|
||||
assert_eq!(http(port, &request(format!("evil.example:{port}"))).await.0, 403, "never a host name");
|
||||
|
||||
// Not an address of this computer: the server does not start, and says which address.
|
||||
let elsewhere = ServerOptions { address: Some("192.0.2.1".parse().unwrap()), ..Default::default() };
|
||||
let error = McpServer::start_with(registry, None, &runtime, elsewhere).err().expect("no such address here");
|
||||
assert!(error.to_string().contains("192.0.2.1"), "{error}");
|
||||
}
|
||||
@@ -234,6 +234,11 @@ pub trait HostApi: Send + Sync {
|
||||
let _ = (path, set, item);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Replace all `(path, icon)` entries of a tree decoration set at once (API 1.8).
|
||||
fn file_icon_decorations(&self, set: &str, items: Vec<(PathBuf, String)>) -> Result<(), HostError> {
|
||||
let _ = (set, items);
|
||||
Err(HostError::Failed("decorations unavailable".into()))
|
||||
}
|
||||
/// Trusted repository history.
|
||||
fn vcs_log(
|
||||
&self,
|
||||
|
||||
@@ -151,6 +151,67 @@ pub fn resolve_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves an already-open document for a background edit without following a
|
||||
/// symbolic link, junction, or other Windows reparse point. Unlike ordinary
|
||||
/// project reads, background edits may have originated with another machine:
|
||||
/// accepting a link would let that machine change an unexpected file when the
|
||||
/// user later saves the editor buffer.
|
||||
///
|
||||
/// The input is deliberately project-relative. Existing path components are
|
||||
/// examined with `symlink_metadata` from the canonical project root; no
|
||||
/// canonicalization happens after the walk, so a link is never traversed.
|
||||
pub fn resolve_background_document_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
if path.is_empty() || Path::new(path).is_absolute() {
|
||||
return Err(format!("{path}: background document paths must be project-relative"));
|
||||
}
|
||||
let root = dunce::canonicalize(root).map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let components: Vec<_> = Path::new(path).components().collect();
|
||||
if components.is_empty() || components.iter().any(|part| !matches!(part, std::path::Component::Normal(_))) {
|
||||
return Err(format!("{path}: background document paths must not leave the project"));
|
||||
}
|
||||
let mut current = root.clone();
|
||||
for (index, part) in components.iter().enumerate() {
|
||||
let std::path::Component::Normal(name) = part else { unreachable!() };
|
||||
if background_control_name(name) {
|
||||
return Err(format!("{path}: background edits may not target VCS or IDE control files"));
|
||||
}
|
||||
current.push(name);
|
||||
let metadata = std::fs::symlink_metadata(¤t).map_err(|_| format!("{path}: no such open document"))?;
|
||||
if is_link_or_reparse(&metadata) {
|
||||
return Err(format!("{path}: background edits refuse symbolic links"));
|
||||
}
|
||||
if index + 1 < components.len() && !metadata.is_dir() {
|
||||
return Err(format!("{path}: not a directory"));
|
||||
}
|
||||
if index + 1 == components.len() && !metadata.is_file() {
|
||||
return Err(format!("{path}: not a file"));
|
||||
}
|
||||
}
|
||||
Ok(current)
|
||||
}
|
||||
|
||||
/// [`resolve_background_document_in_root`] over a workspace group's roots.
|
||||
pub fn resolve_background_document_in_roots(roots: &[PathBuf], path: &str) -> Result<PathBuf, String> {
|
||||
first_root_resolving(roots, path, resolve_background_document_in_root)
|
||||
}
|
||||
|
||||
fn is_link_or_reparse(metadata: &std::fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
let reparse = {
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
metadata.file_attributes() & 0x400 != 0 // FILE_ATTRIBUTE_REPARSE_POINT
|
||||
};
|
||||
#[cfg(not(windows))]
|
||||
let reparse = false;
|
||||
metadata.file_type().is_symlink() || reparse
|
||||
}
|
||||
|
||||
fn background_control_name(name: &std::ffi::OsStr) -> bool {
|
||||
let name = name.to_string_lossy();
|
||||
let name = name.split(':').next().unwrap_or_default().trim_end_matches(['.', ' ']);
|
||||
matches!(name.to_ascii_lowercase().as_str(), ".git" | ".hg" | ".svn" | ".idea" | ".openidea" | ".vscode")
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
@@ -705,6 +766,25 @@ mod tests {
|
||||
assert!(!names_git_dir(Path::new(".github/workflows/ci.yml")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_document_edits_are_relative_and_exclude_control_directories() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git/hooks")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
std::fs::write(root.join(".git/hooks/pre-commit"), "#!/bin/sh\n").unwrap();
|
||||
std::fs::write(root.join(".openidea/state"), "state\n").unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "src/main.rs").is_ok());
|
||||
for path in
|
||||
["/etc/passwd", "../outside", "src/../.git/hooks/pre-commit", ".git/hooks/pre-commit", ".openidea/state"]
|
||||
{
|
||||
assert!(resolve_background_document_in_root(&root, path).is_err(), "{path}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn git_folder_is_recognised_under_every_spelling_that_opens_it() {
|
||||
for path in [
|
||||
@@ -740,6 +820,23 @@ mod tests {
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn background_document_edits_refuse_final_and_ancestor_symlinks() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
|
||||
symlink(root.join("src/main.rs"), root.join("final-link.rs")).unwrap();
|
||||
symlink(root.join("src"), root.join("linked-src")).unwrap();
|
||||
|
||||
assert!(resolve_background_document_in_root(&root, "final-link.rs").unwrap_err().contains("symbolic links"));
|
||||
assert!(
|
||||
resolve_background_document_in_root(&root, "linked-src/main.rs").unwrap_err().contains("symbolic links")
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_paths_follow_existing_and_dangling_symlinks() {
|
||||
|
||||
@@ -932,6 +932,9 @@ pub struct FileDecoration {
|
||||
/// Text shown after the name (API 1.7 `set-file-note`).
|
||||
#[serde(default)]
|
||||
pub note: Option<String>,
|
||||
/// `[[contributes.fileIcons]]` id for this file or folder (API 1.8 `set-file-icon`).
|
||||
#[serde(default)]
|
||||
pub icon: Option<String>,
|
||||
}
|
||||
/// Commit metadata, date in Unix seconds.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -870,7 +870,7 @@ impl editor::Host for StoreData {
|
||||
return Ok(Err(wit::Error::Invalid("edits too large".into())));
|
||||
}
|
||||
let root = self.table.get(&project)?.root.clone();
|
||||
let path = match self.resolve_in_project(&root, &path) {
|
||||
let path = match self.resolve_background_document_in_project(&root, &path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => return Ok(Err(wit::Error::Invalid(error))),
|
||||
};
|
||||
@@ -936,6 +936,14 @@ impl StoreData {
|
||||
sandbox::resolve_writable_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// A background document edit is the only plugin operation that changes an
|
||||
/// editor on behalf of a remote peer. It therefore rejects links and IDE
|
||||
/// control directories instead of using the normal read resolver.
|
||||
fn resolve_background_document_in_project(&self, root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
sandbox::resolve_background_document_in_root(root, path).or_else(|root_error| {
|
||||
sandbox::resolve_background_document_in_roots(&self.host.member_roots(root), path).map_err(|_| root_error)
|
||||
})
|
||||
}
|
||||
/// Whether a `project` resource's path lands inside the resource's root or
|
||||
/// any member of its workspace group, without requiring it to exist.
|
||||
fn inside_project(&self, root: &Path, path: &str) -> Result<bool, String> {
|
||||
@@ -2497,7 +2505,13 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
.file_decorations(
|
||||
Some(&real),
|
||||
&set_id,
|
||||
Some(crate::FileDecoration { badge: item.badge, style: item.style, tooltip: item.tooltip, note: None }),
|
||||
Some(crate::FileDecoration {
|
||||
badge: item.badge,
|
||||
style: item.style,
|
||||
tooltip: item.tooltip,
|
||||
note: None,
|
||||
icon: None,
|
||||
}),
|
||||
)
|
||||
.map_err(to_wit))
|
||||
}
|
||||
@@ -2515,9 +2529,36 @@ impl bindings::openidea::plugin::decorations::Host for StoreData {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note) };
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: Some(note), icon: None };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icon(&mut self, path: String, set_id: String, icon: String, tooltip: Option<String>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let real = match self.decoration_path(&path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(e)),
|
||||
};
|
||||
let item = crate::FileDecoration { badge: None, style: String::new(), tooltip, note: None, icon: Some(icon) };
|
||||
Ok(self.host.file_decorations(Some(&real), &set_id, Some(item)).map_err(to_wit))
|
||||
}
|
||||
fn set_file_icons(&mut self, set_id: String, items: Vec<(String, String)>) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if items.len() > 10_000 {
|
||||
return Ok(Err(wit::Error::Invalid("at most 10000 file icons per set".into())));
|
||||
}
|
||||
let mut resolved = Vec::with_capacity(items.len());
|
||||
for (path, icon) in items {
|
||||
match self.decoration_path(&path) {
|
||||
Ok(real) => resolved.push((real, icon)),
|
||||
Err(e) => return Ok(Err(e)),
|
||||
}
|
||||
}
|
||||
Ok(self.host.file_icon_decorations(&set_id, resolved).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) mod language_features_bindings {
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
6c50332c868a890b91a7f92ef7f92e76a4818b7435291e99c77c0b9f0931b81c
|
||||
e616b87b24371581ad648f9c7072259de7732d122b5887b488f7bd480ebd69c2
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
fdcb208365db90bcd06d0d0df3d436b69d1229c40f7c63dd6f1ab768e0d236b8
|
||||
75eeecc467bff141bda14f1f080633348af348f7259e793be81e6548d2008669
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3108abe8d381b224ec4b44ecf4c43c045671bd5e221ee21582e25497f8c14dff
|
||||
4aaf7754e895f022d34dad05bd2b8018e3a61ba18771b8c95d9f070cb212530c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
876a84b7da5a4b27fce47cf71a4dc28eb1ea6c9d54283c1b73a2ae2411653bf5
|
||||
e573144879a7caa0975e17b233476eb317ff54bef23fd8fa64280d3649bad700
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
234dc693e01e484b91fa7f2d0ac0e7757fb69c9fba81f06bf8ef1cd3624228d8
|
||||
36dd40ef751a617f1ad59491312889c12481b05666e580f21f7c2552df76edbc
|
||||
@@ -39,12 +39,8 @@ impl Plugin for Fixture {
|
||||
hover: None,
|
||||
after: Some(after.into()),
|
||||
};
|
||||
host::set_decorations(
|
||||
path,
|
||||
"fixture",
|
||||
&[item("typo", "typo message"), item("weak", "weak message")],
|
||||
)
|
||||
.map(|()| None)
|
||||
host::set_decorations(path, "fixture", &[item("typo", "typo message"), item("weak", "weak message")])
|
||||
.map(|()| None)
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
927c329fd21d12fd5f423b937e1be756be45a4c6dceee08679e762d5d9f511f2
|
||||
724ed3bbd828e4fc1925c1b555bdaec9268633cb587806a661f8838e5a540545
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
e7c77fde915e0765f91929dba2aa8eff606f2302e078b7305d272a4de5b9d80b
|
||||
1a3685234f1b3b0957184c44c603c0eefe7e3084b88d0df32da268b29292ee92
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
3feb9df8b408dedbc3586864db00b92c8cb244c8ad5b8f10c95edb8eb3d30df1
|
||||
4283de821d69a18438f58062e4bc28b63ba5025cc5f9fa51f21ff475b677a6a5
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
3bfce29f90b9d2f20082b9bdd0957c6c58c265a346305ec948f514537a4579c8
|
||||
0f25487b8e488e5ec01fabd2951bdb03d80bc26e4e9aa508394a7f9cd09d1759
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
ca72c5cfa2f76a93bc07495d50e58181a9c6c2fc75854cf548bb0278814ddb87
|
||||
80f9dc28e4f168dd22ccee1c53e1f6b08c446622eb10f07de29be5fc45e9fb1c
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
8a4a7df8fd6cf397750ac24cedf06174ed50e4214ef44029e29f7d0557d426ba
|
||||
5355cb4ef373eba3c54806eb00294fcd5fccb3cf05dae3e1c2a4ad45ab958f03
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
8d12313675e0869e087ac4c79bdfb1a9738867e28af2437eff27cb07254f572b
|
||||
fc75ede42e6db7619fa28fc8999f091d39e3c1ed401356db3a3f07de54287c99
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
1b2ad52d4cbb4abb662be5772f050844836f8e3f0c525edd0de19ff45e95a148
|
||||
8793601496f1727cf912e4cdf86ec48fc340a17585098e98dee52a125cb8a16d
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
13babf73d8d3d029aba26641b01a4add3abb3dab84394d8bd676edb064dc0c19
|
||||
fb2db14a645cad42941d20a25a3ff87ca6fdee635fdb40953383ff30ce16af8a
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
a7790bf28783d57ed47f635a58981b80ddaecd7074cf88c539465b57132a9f8f
|
||||
fc66b3b56a1ab1c9d547e3efe6bb4693dca908bb18be8b8ebd26f2fb41e4436c
|
||||
Бінарний файл не відображається.
@@ -1 +1 @@
|
||||
af7070f6ca3a14cf480260aa21b7a6e95038996d224d40e53a487c96a8f3acf2
|
||||
c76830df9dee3caba932ffaddaabce88ac7ef23b3957b3e8e570b4939dd58d9d
|
||||
Бінарний файл не відображається.
+1
-1
@@ -1 +1 @@
|
||||
16930ecfe74d096e6dcaa5fbf35664128b4d3844f47993a947aabe9d2a9650de
|
||||
3a41a06d54939a9e85fcf6b0bf15b336f928a3caf66576bd2da62c5b8b177c1d
|
||||
Бінарний файл не відображається.
Loaded 100 of 536 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача