mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05b62611ff |
No files matched your search
@@ -22,8 +22,6 @@ env:
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# The schedule is for this repository's release; forks can still run it by hand.
|
||||
if: github.event_name != 'schedule' || github.repository == 'l0ng-ai/openidea'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
|
||||
Generated
+38
-227
@@ -60,20 +60,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"version_check",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.5"
|
||||
@@ -630,12 +616,6 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -710,12 +690,6 @@ dependencies = [
|
||||
"allocator-api2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -2056,15 +2030,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
@@ -2222,17 +2187,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.16.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998b056554fbe42e03ae0e152895cd1a7e1002aec800fdc6635d20270260c46f"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "faster-hex"
|
||||
version = "0.10.1"
|
||||
@@ -2333,17 +2287,6 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1918b65d96df47d3591bed19c5cca17e3fa5d0707318e4b5ef2eae01764df7e5"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flume"
|
||||
version = "0.12.0"
|
||||
@@ -2403,16 +2346,6 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
"num",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-set-times"
|
||||
version = "0.20.3"
|
||||
@@ -3974,12 +3907,6 @@ version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
||||
|
||||
[[package]]
|
||||
name = "httpdate"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
@@ -4006,7 +3933,6 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"httparse",
|
||||
"httpdate",
|
||||
"itoa",
|
||||
"pin-project-lite",
|
||||
"smallvec",
|
||||
@@ -4180,13 +4106,12 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-mcp",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
@@ -4204,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4218,7 +4143,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-android"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4229,7 +4154,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
@@ -4243,23 +4168,9 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-cli"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"winreg 0.56.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4268,7 +4179,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4285,7 +4196,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4299,7 +4210,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4320,12 +4231,10 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
@@ -4343,14 +4252,13 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"serde",
|
||||
@@ -4363,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-extension"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4383,7 +4291,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4395,7 +4303,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4408,7 +4316,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -4416,7 +4324,6 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
"getrandom 0.3.4",
|
||||
"globset",
|
||||
"ide-agent",
|
||||
@@ -4433,7 +4340,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-remote",
|
||||
@@ -4450,10 +4356,8 @@ dependencies = [
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"jsonschema",
|
||||
"parking_lot",
|
||||
"quick-xml 0.42.0",
|
||||
"regex-lite",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
@@ -4474,7 +4378,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4492,7 +4396,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4514,7 +4418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4531,7 +4435,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4587,7 +4491,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
@@ -4611,28 +4515,9 @@ dependencies = [
|
||||
"zip",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-mcp"
|
||||
version = "0.1.39"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"dunce",
|
||||
"getrandom 0.3.4",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4655,10 +4540,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"ide-core",
|
||||
"ide-extension",
|
||||
"parking_lot",
|
||||
@@ -4670,7 +4554,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"url",
|
||||
"wasmtime",
|
||||
"wasmtime-wasi",
|
||||
"wat",
|
||||
@@ -4678,7 +4561,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4691,9 +4574,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4710,7 +4592,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4734,7 +4616,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4753,7 +4635,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4773,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4786,7 +4668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4807,7 +4689,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4822,7 +4704,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4835,7 +4717,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4848,7 +4730,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4869,7 +4751,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -4890,7 +4772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4914,7 +4796,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4930,7 +4812,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -5361,32 +5243,6 @@ dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"base64 0.22.1",
|
||||
"bytecount",
|
||||
"email_address",
|
||||
"fancy-regex",
|
||||
"fraction",
|
||||
"idna",
|
||||
"itoa",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"regex-syntax",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jupyter-protocol"
|
||||
version = "2.0.3"
|
||||
@@ -5620,7 +5476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"fluent-uri 0.1.4",
|
||||
"fluent-uri",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@@ -5962,12 +5818,6 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
@@ -6508,7 +6358,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
@@ -6520,7 +6370,6 @@ dependencies = [
|
||||
"ide-analysis",
|
||||
"ide-android",
|
||||
"ide-build",
|
||||
"ide-cli",
|
||||
"ide-clipboard",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
@@ -6536,7 +6385,6 @@ dependencies = [
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-mcp",
|
||||
"ide-notebook",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
@@ -6645,12 +6493,6 @@ dependencies = [
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "p256"
|
||||
version = "0.14.0"
|
||||
@@ -7418,20 +7260,6 @@ dependencies = [
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.33.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e9c261f7ce75418b3beadfb3f0eb1299fe8eb9640deba45ffa2cb783098697d"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri 0.3.2",
|
||||
"once_cell",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regalloc2"
|
||||
version = "0.15.2"
|
||||
@@ -10622,17 +10450,6 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"uuid",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -10657,12 +10474,6 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
|
||||
+1
-3
@@ -5,7 +5,7 @@ members = ["crates/*", "src-tauri"]
|
||||
exclude = ["sdk", "plugins"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.39"
|
||||
version = "0.1.38"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
@@ -42,11 +42,9 @@ ide-fonts = { path = "crates/ide-fonts" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
ide-mcp = { path = "crates/ide-mcp" }
|
||||
ide-notebook = { path = "crates/ide-notebook" }
|
||||
ide-extension = { path = "crates/ide-extension" }
|
||||
ide-plugin = { path = "crates/ide-plugin" }
|
||||
ide-cli = { path = "crates/ide-cli" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
|
||||
@@ -9,7 +9,6 @@ license.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
@@ -22,5 +21,7 @@ tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -27,29 +27,6 @@ use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU8, Ordering};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
pub(crate) const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
// Covers signed-out users and refused accounts (Claude Code: "API Error: 403").
|
||||
pub(crate) fn auth_required(code: i64, message: &str) -> bool {
|
||||
if code == AUTH_REQUIRED {
|
||||
return true;
|
||||
}
|
||||
let lower = message.to_lowercase();
|
||||
let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
|
||||
words.iter().any(|w| {
|
||||
*w == "auth"
|
||||
|| w.starts_with("authenticat")
|
||||
|| w.starts_with("authoriz")
|
||||
|| w.starts_with("unauthenticat")
|
||||
|| w.starts_with("unauthoriz")
|
||||
|| w.starts_with("oauth")
|
||||
}) || words.windows(3).any(|w| w == ["not", "logged", "in"])
|
||||
|| words
|
||||
.windows(2)
|
||||
.any(|w| w == ["logged", "out"] || w == ["sign", "in"] || w == ["log", "in"] || w == ["login", "required"])
|
||||
}
|
||||
|
||||
/// The capabilities this client offers, in v1 form.
|
||||
fn client_capabilities() -> Value {
|
||||
json!({
|
||||
@@ -92,11 +69,6 @@ pub(crate) struct Negotiated {
|
||||
}
|
||||
|
||||
impl Negotiated {
|
||||
pub fn mcp_stdio(&self) -> bool {
|
||||
// Stdio is the ACP v1 baseline; v2 negotiates each transport explicitly.
|
||||
self.capabilities.pointer("/mcpCapabilities/stdio").and_then(Value::as_bool).unwrap_or(self.generation == 1)
|
||||
}
|
||||
|
||||
/// Reads an `initialize` response. The shape decides, not `protocolVersion`: some v1
|
||||
/// agents report version 2 with v1 fields (`agentInfo`, `agentCapabilities`); only a
|
||||
/// v2 response has `info`.
|
||||
@@ -125,7 +97,7 @@ impl Negotiated {
|
||||
"audio": has(prompt, "audio"),
|
||||
"embeddedContext": has(prompt, "embeddedContext"),
|
||||
},
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false, "stdio": has(mcp, "stdio") },
|
||||
"mcpCapabilities": { "http": has(mcp, "http"), "sse": false },
|
||||
});
|
||||
if session.is_some() {
|
||||
let mut sc = json!({ "list": {}, "resume": {}, "close": {} });
|
||||
|
||||
@@ -16,42 +16,6 @@ const CANCEL: &str = include_str!("../../tests/fixtures/acp-v2/cancel.jsonl");
|
||||
const RESUME: &str = include_str!("../../tests/fixtures/acp-v2/resume.jsonl");
|
||||
const INITIALIZE_V1: &str = include_str!("../../tests/fixtures/acp-v2/initialize-v1.jsonl");
|
||||
|
||||
#[test]
|
||||
fn auth_required_matches_authentication_words_and_sign_in_phrases() {
|
||||
for (code, message, expected) in [
|
||||
(AUTH_REQUIRED, "This account is not eligible.", true),
|
||||
(codes::INTERNAL_ERROR, "Auth required", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 401", true),
|
||||
(codes::INTERNAL_ERROR, "Failed to authenticate. API Error: 403", true),
|
||||
(codes::INTERNAL_ERROR, "Not authenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Authorization failed", true),
|
||||
(codes::INTERNAL_ERROR, "Authorize your account", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthenticated", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthentication error", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", true),
|
||||
(codes::INTERNAL_ERROR, "Unauthorization error", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "OAuth2 token has expired", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in", true),
|
||||
(codes::INTERNAL_ERROR, "You are logged out", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in", true),
|
||||
(codes::INTERNAL_ERROR, "Please log in", true),
|
||||
(codes::INTERNAL_ERROR, "Login required", true),
|
||||
(codes::INTERNAL_ERROR, "author", false),
|
||||
(codes::INTERNAL_ERROR, "authors", false),
|
||||
(codes::INTERNAL_ERROR, "authority", false),
|
||||
(codes::INTERNAL_ERROR, "authored", false),
|
||||
(codes::INTERNAL_ERROR, "reauthenticate", false),
|
||||
(codes::INTERNAL_ERROR, "preauthorized", false),
|
||||
(codes::INTERNAL_ERROR, "signal input failed", false),
|
||||
(codes::INTERNAL_ERROR, "catalog index failed", false),
|
||||
(codes::INTERNAL_ERROR, "login succeeded", false),
|
||||
] {
|
||||
assert_eq!(auth_required(code, message), expected, "code={code}, message={message:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The messages of a fixture going `dir` (`in` or `out`).
|
||||
pub(crate) fn frames(fixture: &str, dir: &str) -> Vec<Value> {
|
||||
fixture
|
||||
@@ -563,13 +527,3 @@ async fn v1_requests_are_sent_in_v2() {
|
||||
.collect();
|
||||
assert_eq!(commands, [&json!("28846a4c-c3e8-4a45-a3d0-c19f10d19d1a"), &json!("s1")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stdio_mcp_is_v1_baseline_and_negotiated_in_v2() {
|
||||
assert!(super::Negotiated::from_initialize(&json!({"protocolVersion":1,"agentCapabilities":{}})).mcp_stdio());
|
||||
assert!(!super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{}}})).mcp_stdio());
|
||||
assert!(
|
||||
super::Negotiated::from_initialize(&json!({"info":{},"capabilities":{"session":{"mcp":{"stdio":{}}}}}))
|
||||
.mcp_stdio()
|
||||
);
|
||||
}
|
||||
+13
-106
@@ -2,8 +2,7 @@
|
||||
//! the agent reports them over ACP.
|
||||
|
||||
use crate::model::{
|
||||
AgentCommand, AgentCommandSource, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit,
|
||||
AgentUsage,
|
||||
AgentCommand, AgentConfigOption, AgentConfigValue, AgentConfigVia, AgentCost, AgentLimit, AgentUsage,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -36,32 +35,20 @@ pub(crate) fn add_client_options(provider: &str, config: &mut Vec<AgentConfigOpt
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the current settings approve tool requests without asking: a bypass mode,
|
||||
/// an allow-all permissions option, or a bypass approval policy.
|
||||
pub(crate) fn bypass_approvals(config: &[AgentConfigOption]) -> bool {
|
||||
let selected = |category: &str| {
|
||||
config.iter().find(|o| o.category.as_deref() == Some(category)).and_then(|o| o.current.as_deref())
|
||||
};
|
||||
selected("mode").is_some_and(is_bypass_mode)
|
||||
|| selected("permissions").is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "on" | "true"))
|
||||
|| selected("approval").is_some_and(|v| v.eq_ignore_ascii_case("bypass"))
|
||||
pub(crate) fn bypass_approvals(provider: &str, config: &[AgentConfigOption]) -> bool {
|
||||
provider == "cursor" && config.iter().any(|o| o.id == CURSOR_APPROVAL && o.current.as_deref() == Some("bypass"))
|
||||
}
|
||||
|
||||
/// The mode that approves everything without asking (`bypassPermissions`, `yolo`, `full-access`…), if the agent has one.
|
||||
pub(crate) fn bypass_mode(options: &[AgentConfigOption]) -> Option<(&AgentConfigOption, &AgentConfigValue)> {
|
||||
let option = options.iter().find(|o| o.category.as_deref() == Some("mode"))?;
|
||||
let value = option.values.iter().find(|v| is_bypass_mode(&v.value))?;
|
||||
let value = option.values.iter().find(|v| {
|
||||
let m: String = v.value.to_lowercase().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
})?;
|
||||
Some((option, value))
|
||||
}
|
||||
|
||||
fn is_bypass_mode(mode: &str) -> bool {
|
||||
// Mode ids can be URL-like (`…/session-modes#yolo`), as the composer's mode icon also assumes.
|
||||
let lower = mode.to_lowercase();
|
||||
let m: String =
|
||||
lower.rsplit(['#', '/']).next().unwrap_or_default().chars().filter(char::is_ascii_lowercase).collect();
|
||||
m.contains("bypass") || m == "yolo" || m.contains("fullaccess") || m == "dangerous"
|
||||
}
|
||||
|
||||
/// The settings in a `session/new` or `session/load` result: its select `configOptions`,
|
||||
/// plus the older `modes` / `models` fields for what those don't cover.
|
||||
pub(crate) fn options_of(result: &Value) -> Vec<AgentConfigOption> {
|
||||
@@ -146,22 +133,15 @@ pub(crate) fn set_request(option: &AgentConfigOption, value: &str) -> (&'static
|
||||
}
|
||||
|
||||
/// `available_commands_update`.
|
||||
pub(crate) fn commands_of(update: &Value, provider: &str) -> Vec<AgentCommand> {
|
||||
pub(crate) fn commands_of(update: &Value) -> Vec<AgentCommand> {
|
||||
update
|
||||
.get("availableCommands")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|c| {
|
||||
let name = c.get("name")?.as_str()?.trim_start_matches('/');
|
||||
let source = if provider == "codex-acp" && name.starts_with('$') {
|
||||
AgentCommandSource::Skill
|
||||
} else {
|
||||
c.get("source").and_then(|s| serde_json::from_value(s.clone()).ok()).unwrap_or_default()
|
||||
};
|
||||
Some(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
source,
|
||||
name: c.get("name")?.as_str()?.trim_start_matches('/').to_owned(),
|
||||
description: str_of(c, "description").unwrap_or_default(),
|
||||
hint: c.pointer("/input/hint").and_then(Value::as_str).filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
})
|
||||
@@ -287,85 +267,12 @@ mod tests {
|
||||
assert_eq!(ids(&c), [("model", Some("b")), ("mode", Some("code"))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_bypass_settings_approve_without_asking() {
|
||||
let full_access = options_of(&json!({
|
||||
"modes": {"currentModeId": "full-access", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(bypass_approvals(&full_access));
|
||||
assert_eq!(bypass_mode(&full_access).map(|(_, v)| v.value.as_str()), Some("full-access"));
|
||||
|
||||
let asking = options_of(&json!({
|
||||
"modes": {"currentModeId": "ask", "availableModes": [
|
||||
{"id": "ask", "name": "Ask"}, {"id": "full-access", "name": "Full Access"}
|
||||
]}
|
||||
}));
|
||||
assert!(!bypass_approvals(&asking));
|
||||
assert_eq!(
|
||||
bypass_mode(&asking).map(|(_, v)| v.value.as_str()),
|
||||
Some("full-access"),
|
||||
"the offered bypass mode stays available"
|
||||
);
|
||||
|
||||
let permissions = |current: &str| {
|
||||
options_of(&json!({"configOptions": [{
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select",
|
||||
"currentValue": current, "options": [{"value": "off"}, {"value": "on"}]
|
||||
}]}))
|
||||
};
|
||||
assert!(bypass_approvals(&permissions("on")));
|
||||
assert!(bypass_approvals(&permissions("TRUE")));
|
||||
assert!(!bypass_approvals(&permissions("off")));
|
||||
|
||||
let approval = options_of(&json!({"configOptions": [{
|
||||
"id": "approval", "name": "Approvals", "category": "approval", "type": "select",
|
||||
"currentValue": "bypass", "options": [{"value": "ask"}, {"value": "bypass"}]
|
||||
}]}));
|
||||
assert!(bypass_approvals(&approval));
|
||||
|
||||
// The composer's mode icon splits URL-like ids on `#` / `/`; the backend must agree.
|
||||
let url_mode = options_of(&json!({
|
||||
"modes": {"currentModeId": "https://agentclientprotocol.com/protocol/session-modes#yolo",
|
||||
"availableModes": [{"id": "https://agentclientprotocol.com/protocol/session-modes#yolo"}]}
|
||||
}));
|
||||
assert!(bypass_approvals(&url_mode));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_sources_in_updates_and_stored_tasks() {
|
||||
let update = json!({"availableCommands": [
|
||||
{"name": "review", "source": "skill"},
|
||||
{"name": "$lint"},
|
||||
{"name": "compact"}
|
||||
]});
|
||||
let sources = |provider| commands_of(&update, provider).into_iter().map(|c| c.source).collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
sources("codex-acp"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Skill, AgentCommandSource::Command]
|
||||
);
|
||||
assert_eq!(
|
||||
sources("custom"),
|
||||
[AgentCommandSource::Skill, AgentCommandSource::Command, AgentCommandSource::Command]
|
||||
);
|
||||
let old: AgentCommand =
|
||||
serde_json::from_value(json!({"name": "review", "description": "Review", "hint": null})).unwrap();
|
||||
assert_eq!(old.source, AgentCommandSource::Command);
|
||||
let skill = AgentCommand { source: AgentCommandSource::Skill, ..old };
|
||||
assert_eq!(serde_json::from_value::<AgentCommand>(serde_json::to_value(&skill).unwrap()).unwrap(), skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_usage() {
|
||||
let cmds = commands_of(
|
||||
&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}),
|
||||
"claude",
|
||||
);
|
||||
assert_eq!(cmds[0].source, AgentCommandSource::Command);
|
||||
let cmds = commands_of(&json!({"availableCommands": [
|
||||
{"name": "review", "description": "Review changes", "input": {"hint": "what to review"}},
|
||||
{"name": "/init", "description": "Write CLAUDE.md", "input": null}
|
||||
]}));
|
||||
assert_eq!(cmds[0].hint.as_deref(), Some("what to review"));
|
||||
assert_eq!((cmds[1].name.as_str(), cmds[1].hint.as_ref()), ("init", None));
|
||||
let u = usage_of(&json!({"used": 1200, "size": 200000, "cost": {"amount": 0.42, "currency": "USD"}})).unwrap();
|
||||
|
||||
+46
-147
@@ -100,8 +100,6 @@ struct Shared {
|
||||
providers: RwLock<Vec<provider::ProviderSpec>>,
|
||||
/// Last probe of each provider.
|
||||
probes: Mutex<HashMap<String, AgentProbe>>,
|
||||
/// One pre-session lookup of commands and settings per provider, including failed attempts.
|
||||
command_fetches: Mutex<HashMap<String, tokio::sync::watch::Receiver<bool>>>,
|
||||
/// [`IDLE_RELEASE`], in milliseconds (shorter in tests).
|
||||
idle_release_ms: AtomicU64,
|
||||
}
|
||||
@@ -153,44 +151,6 @@ impl Shared {
|
||||
cache.get(provider).and_then(|k| k.commands.get(&self.root_key())).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn fetch_settings(self: &Arc<Self>, spec: provider::ProviderSpec) {
|
||||
let mut ready = {
|
||||
let mut fetches = self.command_fetches.lock();
|
||||
fetches
|
||||
.entry(spec.id.clone())
|
||||
.or_insert_with(|| {
|
||||
let (done, ready) = tokio::sync::watch::channel(false);
|
||||
let shared = self.clone();
|
||||
// Keep the lookup alive if a composer unmounts while it is waiting.
|
||||
self.runtime.spawn(async move {
|
||||
if let Ok((commands, options)) =
|
||||
native::claude::discover(spec.clone(), shared.paths.project_root.clone()).await
|
||||
{
|
||||
let commands = config::commands_of(&json!({ "availableCommands": commands }), &spec.id);
|
||||
let options = config::options_of(&json!({ "configOptions": options }));
|
||||
let _ = tokio::task::spawn_blocking(move || {
|
||||
shared.remember_discovered(&spec.id, &options, &commands);
|
||||
shared.emit(AgentEvent::Providers);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
let _ = done.send(true);
|
||||
});
|
||||
ready
|
||||
})
|
||||
.clone()
|
||||
};
|
||||
while !*ready.borrow_and_update() {
|
||||
if ready.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn has_config(&self, provider: &str) -> bool {
|
||||
self.known.lock().get(provider).is_some_and(|k| !k.config.is_empty())
|
||||
}
|
||||
|
||||
fn root_key(&self) -> String {
|
||||
self.paths.project_root.to_string_lossy().into_owned()
|
||||
}
|
||||
@@ -203,52 +163,37 @@ impl Shared {
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
) {
|
||||
self.remember_inner(provider, config, commands, model, false);
|
||||
}
|
||||
|
||||
fn remember_discovered(&self, provider: &str, config: &[AgentConfigOption], commands: &[AgentCommand]) {
|
||||
self.remember_inner(provider, Some(config), Some(commands), model_of(config).as_deref(), true);
|
||||
}
|
||||
|
||||
fn remember_inner(
|
||||
&self,
|
||||
provider: &str,
|
||||
config: Option<&[AgentConfigOption]>,
|
||||
commands: Option<&[AgentCommand]>,
|
||||
model: Option<&str>,
|
||||
discovered: bool,
|
||||
) {
|
||||
// Keep the update and its disk write ordered; a session report wins over discovery.
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config.filter(|_| !discovered || known.config.is_empty()) {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
let snapshot = {
|
||||
let mut cache = self.known.lock();
|
||||
let known = cache.entry(provider.to_owned()).or_default();
|
||||
let mut changed = false;
|
||||
if let Some(c) = config {
|
||||
if let Some(model) = model {
|
||||
let levels: Vec<_> =
|
||||
c.iter().filter(|o| o.category.as_deref() == Some("thought_level")).cloned().collect();
|
||||
if known.thought_levels.get(model) != Some(&levels) {
|
||||
known.thought_levels.insert(model.to_owned(), levels);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if !c.is_empty() && known.config != c {
|
||||
known.config = c.to_vec();
|
||||
let root = self.root_key();
|
||||
if let Some(c) =
|
||||
commands.filter(|c| !c.is_empty() && known.commands.get(&root).map(Vec::as_slice) != Some(c))
|
||||
{
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
let root = self.root_key();
|
||||
if let Some(c) = commands.filter(|c| {
|
||||
!c.is_empty()
|
||||
&& (!discovered || known.commands.get(&root).is_none_or(Vec::is_empty))
|
||||
&& known.commands.get(&root).map(Vec::as_slice) != Some(c)
|
||||
}) {
|
||||
known.commands.insert(root, c.to_vec());
|
||||
changed = true;
|
||||
}
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
if let Ok(bytes) = serde_json::to_vec_pretty(&*cache)
|
||||
if !changed {
|
||||
return;
|
||||
}
|
||||
serde_json::to_vec_pretty(&*cache)
|
||||
};
|
||||
if let Ok(bytes) = snapshot
|
||||
&& let Err(e) = write_file(&self.paths.models_file, &bytes)
|
||||
{
|
||||
tracing::warn!("saving agent settings: {e}");
|
||||
@@ -306,7 +251,6 @@ struct Live {
|
||||
embedded: bool,
|
||||
/// `session/load`: the process can be stopped between turns and the session reloaded.
|
||||
resumable: bool,
|
||||
mcp_stdio: bool,
|
||||
}
|
||||
|
||||
struct Task {
|
||||
@@ -372,7 +316,6 @@ impl AgentManager {
|
||||
known: Mutex::new(known),
|
||||
providers,
|
||||
probes: Mutex::new(HashMap::new()),
|
||||
command_fetches: Mutex::new(HashMap::new()),
|
||||
idle_release_ms: AtomicU64::new(IDLE_RELEASE.as_millis() as u64),
|
||||
});
|
||||
let mut tasks = HashMap::new();
|
||||
@@ -476,21 +419,9 @@ impl AgentManager {
|
||||
// Commands the agent implements itself and never lists (its CLI builtins):
|
||||
// merged without duplicating what `availableCommands` or the disk offer.
|
||||
let mut offered = offered;
|
||||
if task.is_none()
|
||||
&& (offered.is_empty() || !self.shared.has_config(&provider))
|
||||
&& let Some(spec) = self.shared.spec(&provider).filter(|s| s.driver == provider::Driver::Claude)
|
||||
{
|
||||
self.shared.fetch_settings(spec).await;
|
||||
offered = self.shared.known_commands(&provider);
|
||||
}
|
||||
for &(name, description) in builtin_commands(&provider) {
|
||||
if !offered.iter().any(|c| c.name == name) {
|
||||
offered.push(AgentCommand {
|
||||
name: name.to_owned(),
|
||||
description: description.to_owned(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
});
|
||||
offered.push(AgentCommand { name: name.to_owned(), description: description.to_owned(), hint: None });
|
||||
}
|
||||
}
|
||||
if provider != "claude" && provider != "claude-acp" {
|
||||
@@ -765,7 +696,7 @@ impl AgentManager {
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
let reply: Value = live
|
||||
.peer
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": mcp_servers_for(live.mcp_stdio, &source.shared.paths.project_root) }), None)
|
||||
.request("session/fork", json!({ "sessionId": live.session, "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(source.describe(e)))?;
|
||||
let session = reply
|
||||
@@ -1463,22 +1394,13 @@ impl Task {
|
||||
};
|
||||
return Failure { message, limit: Some(resets) };
|
||||
}
|
||||
let guidance = self.live.lock().as_ref().map(|live| live.sign_in_message.clone());
|
||||
Failure::from(self.sign_in_error(&e, guidance.as_deref()).unwrap_or_else(|| self.describe(e)))
|
||||
}
|
||||
|
||||
fn sign_in_error(&self, e: &RpcError, guidance: Option<&str>) -> Option<String> {
|
||||
let RpcError::Server { code, message, .. } = e else { return None };
|
||||
if !acp::auth_required(*code, message) {
|
||||
return None;
|
||||
match e {
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
RpcError::Server { message, .. } if message.to_lowercase().contains("auth") => {
|
||||
Failure::from(self.live.lock().as_ref().map(|live| live.sign_in_message.clone()).unwrap_or(message))
|
||||
}
|
||||
e => Failure::from(self.describe(e)),
|
||||
}
|
||||
tracing::warn!(task = %self.id, code, %message, "agent sign-in failed");
|
||||
let Some(guidance) = guidance else { return Some(message.clone()) };
|
||||
Some(if message.trim().is_empty() || guidance.contains(message) {
|
||||
guidance.to_owned()
|
||||
} else {
|
||||
format!("{guidance}\n\n{message}")
|
||||
})
|
||||
}
|
||||
|
||||
async fn ensure_session(self: &Arc<Self>) -> Result<Live, String> {
|
||||
@@ -1497,6 +1419,7 @@ impl Task {
|
||||
let spec = tokio::task::spawn_blocking(move || spec.resolve_for_launch(&launch_cwd))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
let sign_in_message = spec.sign_in_message();
|
||||
self.stderr.lock().clear();
|
||||
self.config_sent.lock().clear();
|
||||
let weak = Arc::downgrade(self);
|
||||
@@ -1516,9 +1439,7 @@ impl Task {
|
||||
.map_err(|e| format!("Couldn't start {} (`{}`): {e}", spec.name, spec.command))?;
|
||||
|
||||
let init = peer.initialize(&client_info()).await.map_err(|e| self.describe(e))?;
|
||||
let sign_in_message = spec.sign_in_message(&init.auth_methods);
|
||||
let can_load = init.load_session();
|
||||
let mcp_stdio = init.mcp_stdio();
|
||||
let (capabilities, embedded) = capabilities_of(&init);
|
||||
// Agents that compare canonical paths fail in a symlinked directory (macOS `/tmp`).
|
||||
let cwd_s = dunce::canonicalize(&cwd).unwrap_or(cwd).to_string_lossy().into_owned();
|
||||
@@ -1527,7 +1448,7 @@ impl Task {
|
||||
if let (Some(id), true) = (saved, can_load) {
|
||||
self.replaying.store(true, Ordering::Release);
|
||||
let loaded: Result<Value, RpcError> =
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }), None).await;
|
||||
peer.request("session/load", json!({ "sessionId": id, "cwd": cwd_s, "mcpServers": [] }), None).await;
|
||||
tokio::time::sleep(REPLAY_GRACE).await;
|
||||
self.replaying.store(false, Ordering::Release);
|
||||
match loaded {
|
||||
@@ -1540,13 +1461,14 @@ impl Task {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
let v: Value = peer
|
||||
.request(
|
||||
"session/new",
|
||||
json!({ "cwd": cwd_s, "mcpServers": mcp_servers_for(mcp_stdio, &self.shared.paths.project_root) }),
|
||||
None,
|
||||
)
|
||||
.request("session/new", json!({ "cwd": cwd_s, "mcpServers": [] }), None)
|
||||
.await
|
||||
.map_err(|e| self.sign_in_error(&e, Some(&sign_in_message)).unwrap_or_else(|| self.describe(e)))?;
|
||||
.map_err(|e| match e {
|
||||
RpcError::Server { ref message, .. } if message.to_lowercase().contains("auth") => {
|
||||
sign_in_message.clone()
|
||||
}
|
||||
e => self.describe(e),
|
||||
})?;
|
||||
let id = v.get("sessionId").and_then(Value::as_str).ok_or("the agent returned no session")?.to_owned();
|
||||
(id, v)
|
||||
}
|
||||
@@ -1635,8 +1557,7 @@ impl Task {
|
||||
}
|
||||
self.persist();
|
||||
self.emit_task();
|
||||
let live =
|
||||
Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load, mcp_stdio };
|
||||
let live = Live { sign_in_message, peer, session: session_id, capabilities, embedded, resumable: can_load };
|
||||
*self.live.lock() = Some(live.clone());
|
||||
Ok(live)
|
||||
}
|
||||
@@ -1660,7 +1581,6 @@ impl Task {
|
||||
.collect()
|
||||
};
|
||||
if due.is_empty() {
|
||||
self.approve_pending();
|
||||
return;
|
||||
}
|
||||
let task = self.clone();
|
||||
@@ -1690,7 +1610,6 @@ impl Task {
|
||||
Err(e) => tracing::warn!("agent task {}: setting {} to {value} failed: {e}", task.id, option.id),
|
||||
}
|
||||
}
|
||||
task.approve_pending();
|
||||
task.persist();
|
||||
task.emit_task();
|
||||
});
|
||||
@@ -1780,7 +1699,7 @@ impl Task {
|
||||
true
|
||||
}
|
||||
"available_commands_update" => {
|
||||
s.task.commands = config::commands_of(update, &provider);
|
||||
s.task.commands = config::commands_of(update);
|
||||
true
|
||||
}
|
||||
"usage_update" => {
|
||||
@@ -1807,9 +1726,6 @@ impl Task {
|
||||
if kind == "config_option_update" && !self.replaying.load(Ordering::Acquire) {
|
||||
self.apply_late_choices();
|
||||
}
|
||||
if !self.replaying.load(Ordering::Acquire) && kind == "current_mode_update" {
|
||||
self.approve_pending();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if self.replaying.load(Ordering::Acquire) {
|
||||
@@ -1941,7 +1857,7 @@ impl Task {
|
||||
|
||||
fn approve_pending(&self) {
|
||||
let s = self.state.lock();
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.config) {
|
||||
if self.cancel_pending.load(Ordering::Acquire) || !config::bypass_approvals(&s.task.provider, &s.task.config) {
|
||||
return;
|
||||
}
|
||||
let mut pending = self.permissions.lock();
|
||||
@@ -2290,20 +2206,3 @@ mod replay;
|
||||
mod replay_tests;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
// The desktop advertises its relay only while the built-in server is listening.
|
||||
static MCP_EXECUTABLE: parking_lot::RwLock<Option<std::path::PathBuf>> = parking_lot::RwLock::new(None);
|
||||
|
||||
pub fn set_mcp_executable(executable: Option<std::path::PathBuf>) {
|
||||
*MCP_EXECUTABLE.write() = executable;
|
||||
}
|
||||
|
||||
fn mcp_servers_for(supported: bool, root: &std::path::Path) -> Vec<Value> {
|
||||
stdio_mcp_servers(MCP_EXECUTABLE.read().as_deref(), supported, root)
|
||||
}
|
||||
|
||||
fn stdio_mcp_servers(executable: Option<&std::path::Path>, supported: bool, root: &std::path::Path) -> Vec<Value> {
|
||||
let Some(executable) = executable.filter(|_| supported) else { return Vec::new() };
|
||||
let command = ide_mcp::stdio_command(executable, root);
|
||||
vec![json!({ "name": "OpenIDEA", "command": command[0], "args": command[1..], "env": [] })]
|
||||
}
|
||||
@@ -156,14 +156,6 @@ pub struct AgentConfigOption {
|
||||
pub via: AgentConfigVia,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AgentCommandSource {
|
||||
#[default]
|
||||
Command,
|
||||
Skill,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AgentCommand {
|
||||
@@ -172,8 +164,6 @@ pub struct AgentCommand {
|
||||
pub description: String,
|
||||
/// What to type after the command, when it takes input.
|
||||
pub hint: Option<String>,
|
||||
#[serde(default)]
|
||||
pub source: AgentCommandSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -69,63 +69,6 @@ pub(crate) fn connect(
|
||||
Ok(super::serve(shim, client))
|
||||
}
|
||||
|
||||
/// Discover the slash commands and the model, mode and effort options without a prompt,
|
||||
/// ACP session, or pending session file.
|
||||
pub(crate) async fn discover(spec: ProviderSpec, cwd: PathBuf) -> Result<(Vec<Value>, Value), String> {
|
||||
tokio::time::timeout(Duration::from_secs(5), async move {
|
||||
let spec = tokio::task::spawn_blocking(move || (spec.resolve_for_launch(&cwd), cwd));
|
||||
let (spec, cwd) = spec.await.map_err(|e| e.to_string())?;
|
||||
let mut args = spec.args;
|
||||
args.extend(Start::New { id: uuid::Uuid::new_v4().to_string() }.args());
|
||||
let launch = Launch { program: spec.command, args, cwd, env: spec.env };
|
||||
let mut io = cli::Process.launch(&launch, Arc::new(|_| {})).map_err(|e| e.to_string())?;
|
||||
let _stop = io.kill.clone().drop_guard();
|
||||
let mut lines = BufReader::new(io.stdout).lines();
|
||||
let init = control(
|
||||
&mut io.stdin,
|
||||
&mut lines,
|
||||
"commands",
|
||||
json!({ "subtype": "initialize", "perTaskStopAffordance": true }),
|
||||
)
|
||||
.await?
|
||||
.ok_or("Claude Code did not initialize")?;
|
||||
// The effort and model the user configured; without them the options show the defaults.
|
||||
let applied = tokio::time::timeout(
|
||||
Duration::from_secs(2),
|
||||
control(&mut io.stdin, &mut lines, "settings", json!({ "subtype": "get_settings" })),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(Result::ok)
|
||||
.flatten()
|
||||
.unwrap_or_default();
|
||||
let settings = Settings::from_cli(&init, &applied);
|
||||
Ok((settings.commands.clone(), settings.options()))
|
||||
})
|
||||
.await
|
||||
.map_err(|_| "Claude Code command discovery timed out".to_owned())?
|
||||
}
|
||||
|
||||
/// Sends one control request and waits for its response; `None` when the CLI answered with an error.
|
||||
async fn control(
|
||||
stdin: &mut (impl AsyncWriteExt + Unpin),
|
||||
lines: &mut tokio::io::Lines<impl AsyncBufReadExt + Unpin>,
|
||||
id: &str,
|
||||
request: Value,
|
||||
) -> Result<Option<Value>, String> {
|
||||
let line = json!({ "type": "control_request", "request_id": id, "request": request });
|
||||
stdin.write_all(format!("{line}\n").as_bytes()).await.map_err(|e| e.to_string())?;
|
||||
stdin.flush().await.map_err(|e| e.to_string())?;
|
||||
while let Some(line) = lines.next_line().await.map_err(|e| e.to_string())? {
|
||||
let Ok(msg) = serde_json::from_str::<Value>(&line) else { continue };
|
||||
if msg["type"] == "control_response" && msg["response"]["request_id"] == id {
|
||||
let response = &msg["response"];
|
||||
return Ok((response["subtype"] == "success").then(|| response["response"].clone()));
|
||||
}
|
||||
}
|
||||
Err("Claude Code exited".into())
|
||||
}
|
||||
|
||||
struct Config {
|
||||
pub program: String,
|
||||
/// Extra arguments (a custom agent's), before the session's own.
|
||||
@@ -155,7 +98,6 @@ pub(super) struct Shim {
|
||||
struct Session {
|
||||
id: String,
|
||||
cwd: PathBuf,
|
||||
mcp_config: Option<tempfile::TempPath>,
|
||||
shim: Weak<Shim>,
|
||||
st: Mutex<State>,
|
||||
/// Serializes (re)starting the process.
|
||||
@@ -322,24 +264,9 @@ impl Shim {
|
||||
Ok(history::chain(&entries, at.as_deref()).into_iter().cloned().collect())
|
||||
}
|
||||
|
||||
fn add_session(
|
||||
self: &Arc<Self>,
|
||||
id: &str,
|
||||
cwd: PathBuf,
|
||||
start: Start,
|
||||
pending: bool,
|
||||
mcp_config: Option<String>,
|
||||
) -> Arc<Session> {
|
||||
let mcp_config = mcp_config.and_then(|json| match write_mcp_config(&json) {
|
||||
Ok(path) => Some(path),
|
||||
Err(e) => {
|
||||
tracing::warn!("Claude Code session {id}: cannot write its MCP configuration: {e}");
|
||||
None
|
||||
}
|
||||
});
|
||||
fn add_session(self: &Arc<Self>, id: &str, cwd: PathBuf, start: Start, pending: bool) -> Arc<Session> {
|
||||
let session = Arc::new(Session {
|
||||
id: id.to_owned(),
|
||||
mcp_config,
|
||||
shim: Arc::downgrade(self),
|
||||
st: Mutex::new(State {
|
||||
proc: None,
|
||||
@@ -385,7 +312,6 @@ impl Shim {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "embeddedContext": true },
|
||||
"sessionCapabilities": { "fork": {} },
|
||||
"mcpCapabilities": { "stdio": true, "http": true, "sse": true },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true, "backgroundTasks": true } },
|
||||
},
|
||||
"agentInfo": { "name": "claude", "title": "Claude Code", "version": self.version() },
|
||||
@@ -394,13 +320,7 @@ impl Shim {
|
||||
}
|
||||
"session/new" => {
|
||||
let id = new_uuid(false);
|
||||
let session = self.add_session(
|
||||
&id,
|
||||
self.cwd_of(¶ms),
|
||||
Start::New { id: id.clone() },
|
||||
false,
|
||||
mcp_config(¶ms),
|
||||
);
|
||||
let session = self.add_session(&id, self.cwd_of(¶ms), Start::New { id: id.clone() }, false);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
session.announce_commands();
|
||||
Ok(json!({ "sessionId": id, "configOptions": session.st.lock().settings.options() }))
|
||||
@@ -416,7 +336,7 @@ impl Shim {
|
||||
None => return Err(invalid(format!("no Claude Code session {id} in {}", cwd.display()))),
|
||||
};
|
||||
let chain = self.base_chain(&cwd, &start).map_err(internal)?;
|
||||
let session = self.add_session(id, cwd, start, pending, mcp_config(¶ms));
|
||||
let session = self.add_session(id, cwd, start, pending);
|
||||
rt.block_on(session.ensure()).map_err(|e| self.start_error(&session, e))?;
|
||||
if method == "session/load" {
|
||||
session.replay(&chain);
|
||||
@@ -442,7 +362,7 @@ impl Shim {
|
||||
(start, st.settings.clone())
|
||||
};
|
||||
self.save_pending(&id, Some(&start));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true, mcp_config(¶ms));
|
||||
let session = self.add_session(&id, source.cwd.clone(), start, true);
|
||||
session.st.lock().settings = settings;
|
||||
let options = session.st.lock().settings.options();
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
@@ -636,9 +556,6 @@ impl Session {
|
||||
old.end();
|
||||
}
|
||||
let mut args = shim.config.args.clone();
|
||||
if let Some(config) = &self.mcp_config {
|
||||
args.extend(["--mcp-config".to_owned(), config.to_string_lossy().into_owned()]);
|
||||
}
|
||||
args.extend(start.args());
|
||||
let launch =
|
||||
Launch { program: shim.config.program.clone(), args, cwd: self.cwd.clone(), env: shim.config.env.clone() };
|
||||
@@ -1428,38 +1345,3 @@ fn start_from_json(v: &Value) -> Option<Start> {
|
||||
}
|
||||
Some(Start::Fork { from: s("fork")?, id: s("id")?, at: s("at") })
|
||||
}
|
||||
|
||||
/// Writes `json` to a new file only this user can read (removed when the path is dropped).
|
||||
fn write_mcp_config(json: &str) -> std::io::Result<tempfile::TempPath> {
|
||||
let mut file = tempfile::Builder::new().prefix("openidea-mcp-").suffix(".json").tempfile()?;
|
||||
std::io::Write::write_all(&mut file, json.as_bytes())?;
|
||||
Ok(file.into_temp_path())
|
||||
}
|
||||
|
||||
/// ACP `mcpServers` as Claude Code's `--mcp-config` JSON (`None` when there are none).
|
||||
fn mcp_config(params: &Value) -> Option<String> {
|
||||
let servers = params.get("mcpServers")?.as_array()?;
|
||||
let pairs = |v: Option<&Value>| -> serde_json::Map<String, Value> {
|
||||
v.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|p| Some((p.get("name")?.as_str()?.to_owned(), p.get("value")?.clone())))
|
||||
.collect()
|
||||
};
|
||||
let mut out = serde_json::Map::new();
|
||||
for server in servers {
|
||||
let Some(name) = server.get("name").and_then(Value::as_str) else { continue };
|
||||
let entry = match server.get("type").and_then(Value::as_str) {
|
||||
Some(kind @ ("http" | "sse")) => {
|
||||
json!({ "type": kind, "url": server.get("url"), "headers": pairs(server.get("headers")) })
|
||||
}
|
||||
_ => json!({
|
||||
"command": server.get("command"),
|
||||
"args": server.get("args").cloned().unwrap_or_else(|| json!([])),
|
||||
"env": pairs(server.get("env")),
|
||||
}),
|
||||
};
|
||||
out.insert(name.to_owned(), entry);
|
||||
}
|
||||
(!out.is_empty()).then(|| json!({ "mcpServers": out }).to_string())
|
||||
}
|
||||
@@ -1170,29 +1170,3 @@ async fn skipped_questions_have_no_answers() {
|
||||
let cancelled = ask_questions(&input, &json!({}), |_, _| async { None }).await;
|
||||
assert_eq!(cancelled, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_become_an_mcp_config() {
|
||||
assert_eq!(mcp_config(&json!({ "mcpServers": [] })), None);
|
||||
assert_eq!(mcp_config(&json!({})), None);
|
||||
let config = mcp_config(&json!({ "mcpServers": [
|
||||
{ "type": "http", "name": "openidea", "url": "http://127.0.0.1:1/mcp",
|
||||
"headers": [{ "name": "Authorization", "value": "Bearer t" }] },
|
||||
{ "name": "local", "command": "srv", "args": ["-x"], "env": [{ "name": "K", "value": "V" }] },
|
||||
] }))
|
||||
.unwrap();
|
||||
let v: Value = serde_json::from_str(&config).unwrap();
|
||||
assert_eq!(
|
||||
v["mcpServers"]["openidea"],
|
||||
json!({ "type": "http", "url": "http://127.0.0.1:1/mcp", "headers": { "Authorization": "Bearer t" } })
|
||||
);
|
||||
assert_eq!(v["mcpServers"]["local"], json!({ "command": "srv", "args": ["-x"], "env": { "K": "V" } }));
|
||||
|
||||
let path = write_mcp_config(&config).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), config);
|
||||
#[cfg(unix)]
|
||||
assert_eq!(std::os::unix::fs::PermissionsExt::mode(&std::fs::metadata(&path).unwrap().permissions()) & 0o077, 0);
|
||||
let kept = path.to_path_buf();
|
||||
drop(path);
|
||||
assert!(!kept.exists());
|
||||
}
|
||||
@@ -8,7 +8,6 @@ mod translate;
|
||||
mod tests;
|
||||
|
||||
use super::Shim;
|
||||
use crate::acp::AUTH_REQUIRED;
|
||||
use crate::provider::ProviderSpec;
|
||||
use ide_jsonrpc::{Framing, Handler, Peer, RpcError, codes};
|
||||
use parking_lot::Mutex;
|
||||
@@ -28,6 +27,8 @@ const OUTPUT_EVERY: Duration = Duration::from_millis(200);
|
||||
const EARLY_CANCEL: Duration = Duration::from_secs(1);
|
||||
/// How long the client's connection outlives codex.
|
||||
const EXIT_GRACE: Duration = Duration::from_millis(100);
|
||||
/// ACP's error code for a request that needs the user to sign in.
|
||||
const AUTH_REQUIRED: i64 = -32000;
|
||||
|
||||
pub(crate) fn connect(
|
||||
spec: &ProviderSpec,
|
||||
@@ -53,7 +54,6 @@ fn attach(
|
||||
client: OnceLock::new(),
|
||||
state: Mutex::new(State::default()),
|
||||
models: tokio::sync::Mutex::new(None),
|
||||
skills: tokio::sync::Mutex::new(()),
|
||||
});
|
||||
let codex = connect_codex(Arc::new(CodexEvents(Arc::downgrade(&shim))))?;
|
||||
let _ = shim.codex.set(codex.clone());
|
||||
@@ -83,8 +83,6 @@ struct CodexShim {
|
||||
state: Mutex<State>,
|
||||
/// `model/list`, fetched once.
|
||||
models: tokio::sync::Mutex<Option<Arc<Vec<ModelInfo>>>>,
|
||||
/// Keep skill snapshots in request order, including invalidations during session setup.
|
||||
skills: tokio::sync::Mutex<()>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
@@ -206,7 +204,7 @@ fn from_codex(e: RpcError) -> ResponseError {
|
||||
code,
|
||||
codes::PARSE_ERROR | codes::INVALID_REQUEST | codes::METHOD_NOT_FOUND | codes::INVALID_PARAMS
|
||||
);
|
||||
let code = if !protocol && crate::acp::auth_required(code, &message) { AUTH_REQUIRED } else { code };
|
||||
let code = if !protocol && message.to_lowercase().contains("auth") { AUTH_REQUIRED } else { code };
|
||||
ResponseError { code, message, data }
|
||||
}
|
||||
RpcError::Closed => error(codes::INTERNAL_ERROR, "Codex exited"),
|
||||
@@ -266,7 +264,7 @@ impl CodexShim {
|
||||
"agentCapabilities": {
|
||||
"loadSession": true,
|
||||
"promptCapabilities": { "image": true, "audio": false, "embeddedContext": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false, "stdio": true },
|
||||
"mcpCapabilities": { "http": false, "sse": false },
|
||||
"sessionCapabilities": { "fork": {}, "close": {} },
|
||||
"_meta": { "openidea": { "rollback": true, "steer": true } },
|
||||
},
|
||||
@@ -303,20 +301,6 @@ impl CodexShim {
|
||||
models
|
||||
}
|
||||
|
||||
async fn publish_skills(&self, session: &str) {
|
||||
let _listing = self.skills.lock().await;
|
||||
let cwd = self.state.lock().threads.get(session).and_then(|t| t.cwd.clone());
|
||||
let Some(cwd) = cwd else { return };
|
||||
let result =
|
||||
tokio::time::timeout(Duration::from_secs(5), self.call("skills/list", json!({ "cwds": [cwd] }))).await;
|
||||
if let Ok(Ok(result)) = result
|
||||
&& let Some(update) = translate::skill_commands(&result)
|
||||
&& self.state.lock().threads.contains_key(session)
|
||||
{
|
||||
self.emit(session, update);
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers a thread codex opened for a session of its own; returns its `configOptions`.
|
||||
fn open_thread(&self, result: &Value, models: &[ModelInfo], settings: Option<Settings>) -> (String, Value) {
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
@@ -334,16 +318,11 @@ impl CodexShim {
|
||||
|
||||
async fn new_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/start", request).await?;
|
||||
let result = self.call("thread/start", json!({ "cwd": params.get("cwd") })).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, None);
|
||||
if id.is_empty() {
|
||||
return Err(error(codes::INTERNAL_ERROR, "codex started no thread"));
|
||||
}
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -351,18 +330,13 @@ impl CodexShim {
|
||||
async fn load_session(&self, params: &Value) -> Result<Value, ResponseError> {
|
||||
let id = str_param(params, "sessionId")?;
|
||||
let models = self.models().await;
|
||||
let mut request = json!({ "threadId": id, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
let result = self.call("thread/resume", request).await?;
|
||||
let result = self.call("thread/resume", json!({ "threadId": id, "cwd": params.get("cwd") })).await?;
|
||||
let (_, options) = self.open_thread(&result, &models, None);
|
||||
let cwd = self.state.lock().threads.get(id).and_then(|t| t.cwd.clone());
|
||||
let thread = result.get("thread").unwrap_or(&Value::Null);
|
||||
for update in translate::history(thread, cwd.as_deref()) {
|
||||
self.emit(id, update);
|
||||
}
|
||||
self.publish_skills(id).await;
|
||||
Ok(json!({ "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -371,9 +345,6 @@ impl CodexShim {
|
||||
let models = self.models().await;
|
||||
let settings = self.state.lock().threads.get(source).and_then(|t| t.settings.clone());
|
||||
let mut request = json!({ "threadId": source, "cwd": params.get("cwd") });
|
||||
if let Some(config) = translate::mcp_config(params) {
|
||||
request["config"] = config;
|
||||
}
|
||||
if let Some(s) = &settings {
|
||||
request["model"] = json!(s.model_slug(&models));
|
||||
request["approvalPolicy"] = json!(s.mode.approval_policy());
|
||||
@@ -381,7 +352,6 @@ impl CodexShim {
|
||||
}
|
||||
let result = self.call("thread/fork", request).await?;
|
||||
let (id, options) = self.open_thread(&result, &models, settings);
|
||||
self.publish_skills(&id).await;
|
||||
Ok(json!({ "sessionId": id, "configOptions": options }))
|
||||
}
|
||||
|
||||
@@ -571,25 +541,9 @@ impl CodexShim {
|
||||
}
|
||||
}
|
||||
|
||||
fn on_codex_notification(self: &Arc<Self>, method: &str, params: &Value) {
|
||||
fn on_codex_notification(&self, method: &str, params: &Value) {
|
||||
let thread = params.get("threadId").and_then(Value::as_str).unwrap_or_default();
|
||||
match method {
|
||||
"skills/changed" => {
|
||||
let shim = self.clone();
|
||||
let sessions: Vec<String> = self
|
||||
.state
|
||||
.lock()
|
||||
.threads
|
||||
.iter()
|
||||
.filter(|(_, t)| t.settings.is_some() && t.parent.is_none())
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
self.runtime.spawn(async move {
|
||||
for session in sessions {
|
||||
shim.publish_skills(&session).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
"turn/started" => {
|
||||
if let Some(turn) = params.pointer("/turn/id").and_then(Value::as_str) {
|
||||
self.turn_known(thread, turn, true);
|
||||
|
||||
@@ -528,21 +528,12 @@ async fn play<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
|
||||
let mut sent = Vec::new();
|
||||
for (out, frame) in &t.frames {
|
||||
if *out {
|
||||
let actual: Value = loop {
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
// Older recordings predate skill discovery; play an unsupported server.
|
||||
if actual["method"] == "skills/list" && frame["method"] != "skills/list" {
|
||||
let reply = json!({ "id": actual["id"], "error": { "code": -32601, "message": "unsupported" } });
|
||||
write.write_all(format!("{reply}\n").as_bytes()).await.unwrap();
|
||||
continue;
|
||||
}
|
||||
break actual;
|
||||
};
|
||||
let line = tokio::time::timeout(Duration::from_secs(10), lines.next_line())
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("the shim didn't send {frame}"))
|
||||
.unwrap()
|
||||
.unwrap_or_else(|| panic!("the shim closed before {frame}"));
|
||||
let actual: Value = serde_json::from_str(&line).unwrap();
|
||||
match frame.get("method") {
|
||||
Some(method) => {
|
||||
assert_eq!(actual.get("method"), Some(method), "expected {frame}, got {actual}");
|
||||
@@ -632,23 +623,6 @@ async fn auth_failure_names_auth() {
|
||||
assert!(start.get("jsonrpc").is_some(), "codex accepts the jsonrpc field");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_errors_classify_authentication_without_matching_author_or_protocol_errors() {
|
||||
for (code, message, expected) in [
|
||||
(codes::INTERNAL_ERROR, "Missing author field", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority", codes::INTERNAL_ERROR),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: expired token", AUTH_REQUIRED),
|
||||
(codes::INTERNAL_ERROR, "Unauthorized", AUTH_REQUIRED),
|
||||
(codes::METHOD_NOT_FOUND, "authentication method not found", codes::METHOD_NOT_FOUND),
|
||||
(AUTH_REQUIRED, "Account is not eligible", AUTH_REQUIRED),
|
||||
] {
|
||||
let response = from_codex(RpcError::Server { code, message: message.into(), data: Some(json!({"detail": 1})) });
|
||||
assert_eq!(response.code, expected, "{message}");
|
||||
assert_eq!(response.message, message);
|
||||
assert_eq!(response.data, Some(json!({"detail": 1})));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_stop_sent_right_after_the_prompt_interrupts_the_turn() {
|
||||
let (results, _client, frames, _) = replay(Scenario::Interrupt).await;
|
||||
@@ -1053,129 +1027,3 @@ fn v_method(line: &str) -> Option<&str> {
|
||||
let at = line.find("\"method\":\"")? + 10;
|
||||
line[at..].split('"').next()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_mcp_servers_become_thread_configuration() {
|
||||
assert!(mcp_config(&json!({"mcpServers":[]})).is_none());
|
||||
assert_eq!(
|
||||
mcp_config(&json!({"mcpServers":[{"name":"OpenIDEA","command":"/app/openidea",
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[{"name":"K","value":"V"}]}]})),
|
||||
Some(
|
||||
json!({"mcp_servers":{"OpenIDEA":{"command":"/app/openidea","args":["--mcp-stdio","--project","/project"],"env":{"K":"V"}}}})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn skills_are_listed_for_sessions_and_refreshed_on_change() {
|
||||
for (method, native) in
|
||||
[("session/new", "thread/start"), ("session/load", "thread/resume"), ("session/fork", "thread/fork")]
|
||||
{
|
||||
for initial in [
|
||||
json!({"result": {"data": [{"cwd": "/work", "errors": [], "skills": [
|
||||
{"name": "review", "enabled": true, "description": "Review", "path": "/work/SKILL.md", "scope": "repo"}
|
||||
]}]}}),
|
||||
json!({"error": {"code": -32601, "message": "unsupported"}}),
|
||||
json!({"result": {}}),
|
||||
] {
|
||||
let listed = initial.pointer("/result/data").is_some();
|
||||
let mut initial = initial;
|
||||
initial["id"] = json!(4);
|
||||
let transcript = Transcript {
|
||||
meta: Value::Null,
|
||||
frames: vec![
|
||||
(true, json!({"id": 1, "method": "initialize"})),
|
||||
(false, json!({"id": 1, "result": {}})),
|
||||
(true, json!({"method": "initialized"})),
|
||||
(true, json!({"id": 2, "method": "model/list"})),
|
||||
(false, json!({"id": 2, "result": {"data": []}})),
|
||||
(true, json!({"id": 3, "method": native})),
|
||||
(
|
||||
false,
|
||||
json!({"id": 3, "result": {"thread": {"id": "s", "cwd": "/work", "turns": []}, "cwd": "/work"}}),
|
||||
),
|
||||
(true, json!({"id": 4, "method": "skills/list"})),
|
||||
(false, initial),
|
||||
(true, json!({"id": 5, "method": "turn/start"})),
|
||||
(false, json!({"id": 5, "error": {"code": -32602, "message": "no model in this test"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 6, "method": "skills/list"})),
|
||||
(false, json!({"id": 6, "result": {"data": [{"cwd": "/work", "skills": [], "errors": []}]}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 7, "method": "skills/list"})),
|
||||
(false, json!({"id": 7, "error": {"code": -32603, "message": "listing failed"}})),
|
||||
(false, json!({"method": "skills/changed", "params": {}})),
|
||||
(true, json!({"id": 8, "method": "skills/list"})),
|
||||
(false, json!({"id": 8, "result": {"data": []}})),
|
||||
],
|
||||
};
|
||||
let (ours, theirs) = tokio::io::duplex(1 << 16);
|
||||
let client = Arc::new(Client::default());
|
||||
let acp = attach(client.clone(), |events| {
|
||||
let (read, write) = tokio::io::split(ours);
|
||||
Ok(Peer::connect_framed(read, write, events, Framing::Lines))
|
||||
})
|
||||
.unwrap();
|
||||
let (read, write) = tokio::io::split(theirs);
|
||||
let player = tokio::spawn(async move { play(&transcript, read, write).await });
|
||||
request(&acp, "initialize", json!({})).await.unwrap();
|
||||
request(&acp, method, json!({"sessionId": "s", "cwd": "/work"})).await.unwrap();
|
||||
let initial_updates = client.of("s");
|
||||
assert_eq!(initial_updates.len(), usize::from(listed), "{method}");
|
||||
if listed {
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["name"], "$review");
|
||||
assert_eq!(initial_updates[0]["availableCommands"][0]["source"], "skill");
|
||||
}
|
||||
assert!(
|
||||
request(&acp, "session/prompt", json!({"sessionId": "s", "prompt": text("/$review check this")}))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
let (frames, _read, _write) = player.await.unwrap();
|
||||
for frame in sent(&frames, "skills/list") {
|
||||
assert_eq!(frame["params"], json!({"cwds": ["/work"]}));
|
||||
}
|
||||
assert_eq!(
|
||||
sent(&frames, "turn/start")[0]["params"]["input"],
|
||||
json!([{"type": "text", "text": "/$review check this"}])
|
||||
);
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while client.of("s").len() < usize::from(listed) + 2 {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let updates = client.of("s");
|
||||
assert_eq!(updates.len(), usize::from(listed) + 2);
|
||||
assert_eq!(updates.last().unwrap()["availableCommands"], json!([]));
|
||||
acp.kill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skills_become_commands_with_their_invocation_names() {
|
||||
let update = skill_commands(&json!({"data": [{"skills": [
|
||||
{"name": "review", "enabled": true, "description": "Long", "shortDescription": "Short"},
|
||||
{"name": "review", "enabled": true, "description": "Duplicate"},
|
||||
{"name": "hidden", "enabled": false, "description": "Disabled"},
|
||||
{"name": "modern", "enabled": true, "description": "Long", "interface": {"shortDescription": "Modern"}},
|
||||
{"name": "full", "enabled": true, "description": "Full description"},
|
||||
{"name": "", "enabled": true}, {"description": "No name"}
|
||||
]}]}))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
update,
|
||||
json!({"sessionUpdate": "available_commands_update", "availableCommands": [
|
||||
{"name": "$review", "description": "Short", "source": "skill", "input": null},
|
||||
{"name": "$modern", "description": "Modern", "source": "skill", "input": null},
|
||||
{"name": "$full", "description": "Full description", "source": "skill", "input": null}
|
||||
]})
|
||||
);
|
||||
let commands = crate::config::commands_of(&update, "codex");
|
||||
assert!(commands.iter().all(|c| c.source == crate::AgentCommandSource::Skill));
|
||||
assert_eq!(commands[0].name, "$review");
|
||||
assert!(skill_commands(&json!({"data": []})).unwrap()["availableCommands"].as_array().unwrap().is_empty());
|
||||
assert!(skill_commands(&json!({})).is_none());
|
||||
}
|
||||
@@ -256,31 +256,6 @@ fn effort_name(effort: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The enabled skills from `skills/list`, named as Codex ACP advertises them.
|
||||
pub(super) fn skill_commands(result: &Value) -> Option<Value> {
|
||||
let entries = result.get("data")?.as_array()?;
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let commands: Vec<Value> = entries
|
||||
.iter()
|
||||
.filter_map(|entry| entry.get("skills").and_then(Value::as_array))
|
||||
.flatten()
|
||||
.filter_map(|skill| {
|
||||
let name = skill.get("name")?.as_str()?.trim();
|
||||
if name.is_empty() || skill.get("enabled") == Some(&Value::Bool(false)) || !seen.insert(name) {
|
||||
return None;
|
||||
}
|
||||
let description = skill
|
||||
.pointer("/interface/shortDescription")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| skill.get("shortDescription").and_then(Value::as_str))
|
||||
.or_else(|| skill.get("description").and_then(Value::as_str))
|
||||
.unwrap_or(name);
|
||||
Some(json!({ "name": format!("${name}"), "description": description, "source": "skill", "input": null }))
|
||||
})
|
||||
.collect();
|
||||
Some(json!({ "sessionUpdate": "available_commands_update", "availableCommands": commands }))
|
||||
}
|
||||
|
||||
/// ACP prompt content blocks as codex `UserInput`s. Embedded files become text.
|
||||
pub(super) fn user_input(prompt: &Value) -> Vec<Value> {
|
||||
let blocks = prompt.as_array().map(Vec::as_slice).unwrap_or_default();
|
||||
@@ -861,31 +836,3 @@ pub(super) fn tail(text: &str) -> String {
|
||||
fn str_of(v: &Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(Value::as_str).map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Session-scoped MCP configuration, carried in thread/start, resume and fork.
|
||||
pub(super) fn mcp_config(params: &Value) -> Option<Value> {
|
||||
let mut servers = serde_json::Map::new();
|
||||
for server in params.get("mcpServers")?.as_array()? {
|
||||
if server.get("type").and_then(Value::as_str).is_some_and(|t| t != "stdio") {
|
||||
continue;
|
||||
}
|
||||
let (Some(name), Some(command)) =
|
||||
(server.get("name").and_then(Value::as_str), server.get("command").and_then(Value::as_str))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let env: serde_json::Map<String, Value> = server
|
||||
.get("env")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| Some((v.get("name")?.as_str()?.to_owned(), v.get("value")?.clone())))
|
||||
.collect();
|
||||
servers.insert(
|
||||
name.into(),
|
||||
json!({"command":command,
|
||||
"args":server.get("args").cloned().unwrap_or_else(||json!([])),"env":env}),
|
||||
);
|
||||
}
|
||||
(!servers.is_empty()).then(|| json!({"mcp_servers":servers}))
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
use crate::{AgentCommandReadOnlyReason, AgentProvider, AgentSource};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -93,26 +92,9 @@ impl ProviderSpec {
|
||||
render_login_hint(self, program, on_path.as_deref(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub(crate) fn sign_in_message(&self, auth_methods: &[Value]) -> String {
|
||||
pub(crate) fn sign_in_message(&self) -> String {
|
||||
let name = &self.name;
|
||||
let program = Path::new(&self.command);
|
||||
let terminal_args = matches!(self.source, AgentSource::Registry { .. })
|
||||
.then(|| {
|
||||
auth_methods.iter().find_map(|method| {
|
||||
if method.get("type").and_then(Value::as_str) != Some("terminal") {
|
||||
return None;
|
||||
}
|
||||
method.get("args")?.as_array()?.iter().map(Value::as_str).collect::<Option<Vec<_>>>()
|
||||
})
|
||||
})
|
||||
.flatten();
|
||||
let hint = match terminal_args {
|
||||
Some(args) => {
|
||||
let command = render_login_command(program, args, cfg!(windows));
|
||||
format!("Run `{command}` in a terminal.")
|
||||
}
|
||||
None => self.login_hint(program),
|
||||
};
|
||||
let hint = self.login_hint(Path::new(&self.command));
|
||||
format!("{name} needs you to sign in. {hint}")
|
||||
}
|
||||
|
||||
@@ -142,8 +124,14 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
if on_path.is_some_and(|path| same_path(path, program)) {
|
||||
return spec.login_hint.clone();
|
||||
}
|
||||
let path = program.to_string_lossy();
|
||||
let quoted = if windows {
|
||||
format!("& '{}'", path.replace('\'', "''"))
|
||||
} else {
|
||||
format!("'{}'", path.replace('\'', "'\\''"))
|
||||
};
|
||||
let (args, suffix) = rest.split_once('`').expect("built-in login hints delimit their command");
|
||||
let command = render_login_command(program, args.split_whitespace(), windows);
|
||||
let command = format!("{quoted}{args}");
|
||||
match suffix {
|
||||
" in a terminal." => format!("Run `{command}` in a terminal."),
|
||||
" in a terminal and sign in." => format!("Run `{command}` in a terminal and sign in."),
|
||||
@@ -151,22 +139,6 @@ fn render_login_hint(spec: &ProviderSpec, program: &Path, on_path: Option<&Path>
|
||||
}
|
||||
}
|
||||
|
||||
fn render_login_command<'a>(program: &Path, args: impl IntoIterator<Item = &'a str>, windows: bool) -> String {
|
||||
let quote = |word: &str| {
|
||||
if windows { format!("'{}'", word.replace('\'', "''")) } else { format!("'{}'", word.replace('\'', "'\\''")) }
|
||||
};
|
||||
let mut command = format!("{}{}", if windows { "& " } else { "" }, quote(&program.to_string_lossy()));
|
||||
for arg in args {
|
||||
command.push(' ');
|
||||
if !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_=./".contains(c)) {
|
||||
command.push_str(arg);
|
||||
} else {
|
||||
command.push_str("e(arg));
|
||||
}
|
||||
}
|
||||
command
|
||||
}
|
||||
|
||||
/// Whether `id` names a built-in agent that needs no registry install (native drivers
|
||||
/// and `npx -y` adapters). CLI built-ins (`copilot`, `gemini`, …) can be installed and
|
||||
/// updated from the ACP Registry into `<config>/agents/<id>.toml`.
|
||||
@@ -398,7 +370,6 @@ fn is_executable(p: &Path) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn login_hints_cover_all_builtin_commands_and_preserve_separate_login_tools() {
|
||||
@@ -458,43 +429,12 @@ mod tests {
|
||||
cursor.env.push(("PATH".into(), dir.path().to_string_lossy().into_owned()));
|
||||
let resolved = cursor.clone().resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), program);
|
||||
assert!(resolved.sign_in_message(&[]).contains(&*program.to_string_lossy()));
|
||||
assert!(resolved.sign_in_message().contains(&*program.to_string_lossy()));
|
||||
cursor.command = format!("tools/{command}");
|
||||
let resolved = cursor.resolve_for_launch(dir.path());
|
||||
assert_eq!(Path::new(&resolved.command), dir.path().join("tools").join(command));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_login_commands_quote_the_binary_and_each_argument() {
|
||||
for (windows, expected) in [
|
||||
(false, "'/agent'\\''s tools/$cli' auth login --account 'user name' 'a'\\''b' '$token' ''"),
|
||||
(true, "& '/agent''s tools/$cli' auth login --account 'user name' 'a''b' '$token' ''"),
|
||||
] {
|
||||
assert_eq!(
|
||||
render_login_command(
|
||||
Path::new("/agent's tools/$cli"),
|
||||
["auth", "login", "--account", "user name", "a'b", "$token", ""],
|
||||
windows
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let mut spec = builtin_spec("cursor").unwrap();
|
||||
spec.id = "custom".into();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
for method in [
|
||||
json!({"type": "terminal"}),
|
||||
json!({"type": "terminal", "args": ["login", null]}),
|
||||
json!({"type": "agent", "args": ["login"]}),
|
||||
] {
|
||||
assert_eq!(
|
||||
spec.sign_in_message(&[method]),
|
||||
"Cursor needs you to sign in. Sign in with the agent's own command line first."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn registry_login_hint_names_the_installed_executable() {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! skills (`.claude/skills/<dir>/SKILL.md`) and custom commands (`.claude/commands/**/*.md`)
|
||||
//! of the working directory and every directory above it, then the user's `~/.claude`.
|
||||
|
||||
use crate::{AgentCommand, AgentCommandSource};
|
||||
use crate::AgentCommand;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -36,14 +36,9 @@ pub(crate) fn discover(cwd: &Path, home: Option<&Path>) -> Vec<AgentCommand> {
|
||||
|
||||
/// `first` (the agent's own list) plus the commands of `more` it lacks.
|
||||
pub(crate) fn merge(first: Vec<AgentCommand>, more: Vec<AgentCommand>) -> Vec<AgentCommand> {
|
||||
let names: HashSet<String> = first.iter().map(|c| c.name.clone()).collect();
|
||||
let mut out = first;
|
||||
for c in more {
|
||||
if let Some(offered) = out.iter_mut().find(|offered| offered.name == c.name) {
|
||||
offered.source = c.source;
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
}
|
||||
out.extend(more.into_iter().filter(|c| !names.contains(&c.name)));
|
||||
out
|
||||
}
|
||||
|
||||
@@ -66,7 +61,7 @@ fn skills(dir: &Path) -> Vec<AgentCommand> {
|
||||
return None;
|
||||
}
|
||||
let name = meta.get("name").filter(|n| !n.is_empty()).map(str::to_owned).or_else(|| file_name(&path))?;
|
||||
Some(meta.command(name, &text, AgentCommandSource::Skill))
|
||||
Some(meta.command(name, &text))
|
||||
})
|
||||
.collect();
|
||||
out.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
@@ -84,7 +79,7 @@ fn commands(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<AgentCommand>)
|
||||
} else if let Some(stem) = name.strip_suffix(".md")
|
||||
&& let Ok(text) = std::fs::read_to_string(&path)
|
||||
{
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text, AgentCommandSource::Command));
|
||||
out.push(Frontmatter::parse(&text).command(format!("{prefix}{stem}"), &text));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -152,7 +147,7 @@ impl Frontmatter {
|
||||
}
|
||||
|
||||
/// `description`, else the body's first line.
|
||||
fn command(&self, name: String, text: &str, source: AgentCommandSource) -> AgentCommand {
|
||||
fn command(&self, name: String, text: &str) -> AgentCommand {
|
||||
let description = self.get("description").filter(|d| !d.is_empty()).map(str::to_owned).unwrap_or_else(|| {
|
||||
let body = text.get(self.body..).unwrap_or_default();
|
||||
body.lines()
|
||||
@@ -163,7 +158,6 @@ impl Frontmatter {
|
||||
});
|
||||
AgentCommand {
|
||||
name,
|
||||
source,
|
||||
description: truncate(&description),
|
||||
hint: self.get("argument-hint").filter(|h| !h.is_empty()).map(str::to_owned),
|
||||
}
|
||||
@@ -200,20 +194,13 @@ mod tests {
|
||||
}
|
||||
|
||||
fn cmd(name: &str, description: &str) -> AgentCommand {
|
||||
AgentCommand {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
hint: None,
|
||||
source: AgentCommandSource::Command,
|
||||
}
|
||||
AgentCommand { name: name.into(), description: description.into(), hint: None }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_and_user_skills_and_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Home is an ancestor of the project, so the walk stops there instead of climbing
|
||||
// past it into the real profile (a temp dir under the user's home holds its skills).
|
||||
let home = tmp.path().to_path_buf();
|
||||
let home = tmp.path().join("home");
|
||||
let repo = tmp.path().join("work").join("idea");
|
||||
let skills = repo.join(".claude/skills");
|
||||
write(
|
||||
@@ -240,12 +227,6 @@ mod tests {
|
||||
["issue-fix", "triage", "verify", "git:commit", "review", "parent", "lint", "brooks-review"]
|
||||
);
|
||||
let by = |n: &str| found.iter().find(|c| c.name == n).unwrap();
|
||||
for name in ["issue-fix", "triage", "verify", "lint"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Skill);
|
||||
}
|
||||
for name in ["git:commit", "review", "parent", "brooks-review"] {
|
||||
assert_eq!(by(name).source, AgentCommandSource::Command);
|
||||
}
|
||||
assert_eq!(by("triage").description, "One pass over open issues and PRs");
|
||||
assert_eq!(by("issue-fix").description, "Fix an issue");
|
||||
assert_eq!(by("verify").description, "Build and drive the app");
|
||||
@@ -281,14 +262,10 @@ mod tests {
|
||||
fn the_agents_entries_win() {
|
||||
let merged = merge(
|
||||
vec![cmd("triage", "from the agent"), cmd("compact", "Compact")],
|
||||
vec![
|
||||
AgentCommand { source: AgentCommandSource::Skill, ..cmd("triage", "from disk") },
|
||||
cmd("verify", "Verify"),
|
||||
],
|
||||
vec![cmd("triage", "from disk"), cmd("verify", "Verify")],
|
||||
);
|
||||
assert_eq!(names(&merged), ["triage", "compact", "verify"]);
|
||||
assert_eq!(merged[0].description, "from the agent");
|
||||
assert_eq!(merged[0].source, AgentCommandSource::Skill);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -35,10 +35,6 @@ struct FakeAgent {
|
||||
/// Without `loadSession` (a session lives only as long as its process).
|
||||
no_load: bool,
|
||||
auth_on_new: bool,
|
||||
request_error: Option<(i64, String)>,
|
||||
auth_methods: Vec<Value>,
|
||||
/// Hold the late mode reply until the prompt's permission request is already waiting.
|
||||
hold_late_mode: bool,
|
||||
empty_config: AtomicBool,
|
||||
/// Config options `session/new` reports after the model one (per-model efforts, say).
|
||||
extra_options: Mutex<Vec<Value>>,
|
||||
@@ -130,17 +126,7 @@ impl Handler for FakeAgent {
|
||||
if (method == "session/new" && self.auth_on_new)
|
||||
|| (method == "session/prompt" && params.pointer("/prompt/0/text") == Some(&json!("auth")))
|
||||
{
|
||||
return Err(self
|
||||
.request_error
|
||||
.clone()
|
||||
.unwrap_or((codes::INTERNAL_ERROR, "Authentication required".into())));
|
||||
}
|
||||
if self.hold_late_mode
|
||||
&& method == "session/set_config_option"
|
||||
&& params.get("value").and_then(Value::as_str) == Some("full-access")
|
||||
{
|
||||
// Let the prompt's permission request reach the task before the mode changes.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
return Err((codes::INTERNAL_ERROR, "Authentication required".into()));
|
||||
}
|
||||
let session = params.get("sessionId").and_then(Value::as_str).unwrap_or("s1").to_owned();
|
||||
match method {
|
||||
@@ -148,10 +134,8 @@ impl Handler for FakeAgent {
|
||||
if params.pointer("/clientCapabilities/subagents").is_some_and(Value::is_object) {
|
||||
self.calls.lock().push("subagents".into());
|
||||
}
|
||||
Ok(
|
||||
json!({"protocolVersion": 1, "authMethods": self.auth_methods, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}),
|
||||
)
|
||||
Ok(json!({"protocolVersion": 1, "agentCapabilities": {"loadSession": !self.no_load,
|
||||
"sessionCapabilities": {"fork": {}}, "_meta": {"openidea": {"rollback": true, "steer": true, "backgroundTasks": true}}}}))
|
||||
}
|
||||
"session/new" => {
|
||||
if self.empty_config.load(Ordering::Acquire) {
|
||||
@@ -216,29 +200,6 @@ impl Handler for FakeAgent {
|
||||
self.say(&session, "ok");
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "late full access" {
|
||||
// The mode option appears only now; the user's choice is applied late.
|
||||
self.update(
|
||||
&session,
|
||||
json!({"sessionUpdate": "config_option_update", "configOptions": [
|
||||
{"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]}
|
||||
]}),
|
||||
);
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
.block_on(peer.request(
|
||||
"session/request_permission",
|
||||
json!({"sessionId": session, "toolCall": {"toolCallId": "late", "title": "Run cargo test"},
|
||||
"options": [{"optionId": "yes", "name": "Allow", "kind": "allow_once"},
|
||||
{"optionId": "no", "name": "Reject", "kind": "reject_once"}]}),
|
||||
None,
|
||||
))
|
||||
.unwrap();
|
||||
let chosen = answer.pointer("/outcome/optionId").and_then(Value::as_str).unwrap_or("none");
|
||||
self.say(&session, &format!("late chose {chosen}"));
|
||||
return Ok(json!({"stopReason": "end_turn"}));
|
||||
}
|
||||
if text == "ask" {
|
||||
let peer = self.latest();
|
||||
let answer: Value = tokio::runtime::Handle::current()
|
||||
@@ -339,9 +300,6 @@ fn manager(dir: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
}
|
||||
|
||||
fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentManager {
|
||||
// These tests speak to FakeAgent, never an installed CLI, including after a provider reload.
|
||||
let agents = dir.join("agents");
|
||||
provider::set_builtin_command(&agents, "claude", Some(&dir.join("missing-claude").to_string_lossy())).unwrap();
|
||||
let connector: Connector = Arc::new(move |_spec, _cwd, client, _stderr| {
|
||||
let (a, b) = duplex(1 << 20);
|
||||
let (ar, aw) = tokio::io::split(a);
|
||||
@@ -356,171 +314,14 @@ fn manager_at(dir: &Path, models_file: &Path, agent: Arc<FakeAgent>) -> AgentMan
|
||||
store_dir: dir.join("store"),
|
||||
worktrees_dir: dir.join("worktrees"),
|
||||
models_file: models_file.to_path_buf(),
|
||||
custom_agents_dir: Some(agents),
|
||||
// The project is the boundary of the skill walk: nothing above it (such as the
|
||||
// user's profile, when temp lives under it) leaks into the test.
|
||||
home_dir: Some(dir.to_path_buf()),
|
||||
custom_agents_dir: None,
|
||||
home_dir: None,
|
||||
},
|
||||
tokio::runtime::Handle::current(),
|
||||
connector,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn claude_commands_before_session_are_fetched_once_and_saved() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
for outcome in ["success", "session_report", "empty", "settings_timeout", "error", "exit", "timeout", "missing"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let script = root.join("fake-claude");
|
||||
std::fs::write(
|
||||
&script,
|
||||
r#"#!/usr/bin/env python3
|
||||
import json, os, pathlib, sys, time
|
||||
root = pathlib.Path.cwd()
|
||||
(root / 'pid').write_text(str(os.getpid()))
|
||||
with (root / 'launches').open('a') as f:
|
||||
f.write(json.dumps(sys.argv[1:]) + '\n')
|
||||
request = json.loads(sys.stdin.readline())
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(json.dumps(request) + '\n')
|
||||
outcome = os.environ['OUTCOME']
|
||||
if outcome == 'exit':
|
||||
sys.exit(1)
|
||||
if outcome == 'timeout':
|
||||
time.sleep(60)
|
||||
response = {'subtype': outcome, 'request_id': request['request_id']}
|
||||
if outcome in ('success', 'session_report', 'empty', 'settings_timeout'):
|
||||
response['subtype'] = 'success'
|
||||
response['response'] = {'commands': [
|
||||
{'name': 'compact', 'description': 'Free up context', 'argumentHint': '[instructions]'},
|
||||
{'name': 'clear'}, {'name': 'model'}, {'name': 'context'}],
|
||||
'models': [
|
||||
{'value': 'default', 'displayName': 'Default (recommended)', 'description': 'Use the default model',
|
||||
'supportsEffort': True, 'supportedEffortLevels': ['low', 'high']},
|
||||
{'value': 'custom-opus', 'displayName': 'custom-opus', 'description': 'Custom Opus model'}]}
|
||||
if outcome == 'empty':
|
||||
response['response'] = {}
|
||||
else:
|
||||
response['error'] = 'Authentication required'
|
||||
if outcome == 'session_report':
|
||||
(root / 'initialized').touch()
|
||||
while not (root / 'continue').exists():
|
||||
time.sleep(0.01)
|
||||
print(json.dumps({'type': 'control_response', 'response': response}), flush=True)
|
||||
for line in sys.stdin:
|
||||
with (root / 'requests').open('a') as f:
|
||||
f.write(line)
|
||||
request = json.loads(line)
|
||||
if request['request']['subtype'] == 'get_settings':
|
||||
if outcome == 'settings_timeout':
|
||||
time.sleep(60)
|
||||
reply = {'subtype': 'success', 'request_id': request['request_id'],
|
||||
'response': {'applied': {'effort': 'high'}, 'effective': {}}}
|
||||
print(json.dumps({'type': 'control_response', 'response': reply}), flush=True)
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::create_dir_all(root.join(".claude/skills/triage")).unwrap();
|
||||
std::fs::write(root.join(".claude/skills/triage/SKILL.md"), "---\nname: triage\ndescription: Triage\n---\n")
|
||||
.unwrap();
|
||||
let mgr = manager(root, Arc::new(FakeAgent::default()));
|
||||
{
|
||||
let mut providers = mgr.shared.providers.write();
|
||||
let spec = providers.iter_mut().find(|p| p.id == "claude").unwrap();
|
||||
if outcome != "missing" {
|
||||
spec.command = script.to_string_lossy().into_owned();
|
||||
}
|
||||
spec.env = vec![("OUTCOME".into(), outcome.into())];
|
||||
}
|
||||
let concurrent = async {
|
||||
if outcome == "session_report" {
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while !root.join("initialized").exists() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let reported = config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": "default",
|
||||
"options": [{ "value": "default" }, { "value": "custom-opus" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": "low",
|
||||
"options": [{ "value": "default" }, { "value": "low" }, { "value": "high" }] }
|
||||
] }));
|
||||
mgr.shared.remember("claude", Some(&reported), None, Some("default"));
|
||||
std::fs::write(root.join("continue"), "").unwrap();
|
||||
}
|
||||
mgr.commands("claude", None).await
|
||||
};
|
||||
let (first, concurrent) = tokio::join!(mgr.commands("claude", None), concurrent);
|
||||
let first = first.unwrap();
|
||||
assert_eq!(first, concurrent.unwrap());
|
||||
assert_eq!(first, mgr.commands("claude", None).await.unwrap());
|
||||
let names: Vec<_> = first.iter().map(|c| c.name.as_str()).collect();
|
||||
if matches!(outcome, "success" | "session_report" | "settings_timeout") {
|
||||
assert_eq!(names, ["compact", "clear", "model", "context", "triage"]);
|
||||
assert_eq!(first[0].hint.as_deref(), Some("[instructions]"));
|
||||
let restored = manager(root, Arc::new(FakeAgent::default()));
|
||||
assert_eq!(first, restored.commands("claude", None).await.unwrap());
|
||||
// The same handshake lists the models, so the composer has them before any task.
|
||||
for mgr in [&mgr, &restored] {
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
let model = claude.config.iter().find(|o| o.category.as_deref() == Some("model")).unwrap();
|
||||
assert_eq!(model.current.as_deref(), Some("default"));
|
||||
let values: Vec<_> = model.values.iter().map(|v| v.value.as_str()).collect();
|
||||
assert_eq!(values, ["default", "custom-opus"]);
|
||||
let effort =
|
||||
claude.thought_levels["default"].iter().find(|o| o.category.as_deref() == Some("thought_level"));
|
||||
assert_eq!(
|
||||
effort.unwrap().current.as_deref(),
|
||||
Some(match outcome {
|
||||
"settings_timeout" => "default",
|
||||
"session_report" => "low",
|
||||
_ => "high",
|
||||
})
|
||||
);
|
||||
}
|
||||
} else {
|
||||
assert_eq!(names, ["triage"], "{outcome}");
|
||||
assert_eq!(root.join("models.json").exists(), outcome == "empty");
|
||||
let claude = mgr.providers().into_iter().find(|p| p.id == "claude").unwrap();
|
||||
assert!(!claude.config.iter().any(|o| o.category.as_deref() == Some("model")), "{outcome}");
|
||||
}
|
||||
assert!(mgr.tasks.read().is_empty());
|
||||
assert!(!root.join("store").exists());
|
||||
if outcome != "missing" {
|
||||
let launches = std::fs::read_to_string(root.join("launches")).unwrap();
|
||||
assert_eq!(launches.lines().count(), 1, "{outcome}");
|
||||
let args: Vec<String> = serde_json::from_str(launches.trim()).unwrap();
|
||||
assert!(args.windows(2).any(|a| a == ["--input-format", "stream-json"]));
|
||||
let requests = std::fs::read_to_string(root.join("requests")).unwrap();
|
||||
let subtypes: Vec<String> = requests
|
||||
.lines()
|
||||
.map(|l| serde_json::from_str::<Value>(l).unwrap()["request"]["subtype"].as_str().unwrap().to_owned())
|
||||
.collect();
|
||||
// The handshake and the settings read: no prompt or other control request.
|
||||
let expected: &[&str] = if matches!(outcome, "success" | "session_report" | "empty" | "settings_timeout") {
|
||||
&["initialize", "get_settings"]
|
||||
} else {
|
||||
&["initialize"]
|
||||
};
|
||||
assert_eq!(subtypes, expected, "{outcome}");
|
||||
let pid = std::fs::read_to_string(root.join("pid")).unwrap();
|
||||
tokio::time::timeout(Duration::from_secs(2), async {
|
||||
while std::process::Command::new("kill").args(["-0", pid.trim()]).output().unwrap().status.success() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.expect("discovery process must exit");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn authentication_failures_name_the_session_executable_at_start_and_during_prompts() {
|
||||
for auth_on_new in [true, false] {
|
||||
@@ -559,110 +360,7 @@ async fn authentication_failures_name_the_session_executable_at_start_and_during
|
||||
.expect("authentication error");
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.ends_with(" login` in a terminal.\n\nAuthentication required"), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn sign_in_errors_preserve_details_and_leave_unrelated_errors_unchanged() {
|
||||
for auth_on_new in [true, false] {
|
||||
for (code, message, sign_in) in [
|
||||
(-32000, "This Google account is not eligible for this agent.", true),
|
||||
(codes::INTERNAL_ERROR, "Authentication failed: token expired for account alice.", true),
|
||||
(codes::INTERNAL_ERROR, "UNAUTHORIZED: account refused", true),
|
||||
(codes::INTERNAL_ERROR, "You are not logged in to this installation.", true),
|
||||
(codes::INTERNAL_ERROR, "Please sign in with your work account.", true),
|
||||
(codes::INTERNAL_ERROR, "Missing author field", false),
|
||||
(codes::INTERNAL_ERROR, "Unknown authority in configuration", false),
|
||||
] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent =
|
||||
Arc::new(FakeAgent { auth_on_new, request_error: Some((code, message.into())), ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "cursor".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
if !auth_on_new {
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
mgr.prompt(&task.id, text("auth"), false).await.unwrap();
|
||||
}
|
||||
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let error = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Error { message, .. } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
if sign_in {
|
||||
assert!(error.starts_with("Cursor needs you to sign in. Run `"), "{error}");
|
||||
assert!(error.ends_with(&format!("\n\n{message}")), "{error}");
|
||||
} else {
|
||||
assert_eq!(error, message);
|
||||
}
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn registry_sign_in_errors_use_the_advertised_terminal_login() {
|
||||
for terminal in [true, false] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent {
|
||||
auth_on_new: true,
|
||||
request_error: Some((-32000, "This account is not eligible.".into())),
|
||||
auth_methods: vec![json!({"type": if terminal { "terminal" } else { "agent" },
|
||||
"id": "cli", "name": "CLI", "args": ["auth", "login"]})],
|
||||
..Default::default()
|
||||
});
|
||||
let mgr = manager(tmp.path(), agent);
|
||||
let program = tmp.path().join("Registry Tools").join("antigravity.cmd");
|
||||
let mut spec = provider::builtin_spec("cursor").unwrap();
|
||||
spec.id = "antigravity".into();
|
||||
spec.name = "Google Antigravity".into();
|
||||
spec.command = program.to_string_lossy().into_owned();
|
||||
spec.source = AgentSource::Registry { version: "1.4.5".into(), update: None };
|
||||
spec.login_hint = "Sign in with the agent's own command line first.".into();
|
||||
mgr.shared.providers.write().push(spec);
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("again"),
|
||||
provider: "antigravity".into(),
|
||||
config: vec![],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Failed).await;
|
||||
let entries = mgr.entries(&task.id, None).unwrap();
|
||||
let error = entries
|
||||
.iter()
|
||||
.find_map(|e| match e {
|
||||
AgentEntry::Error { message, .. } => Some(message),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(error.starts_with("Google Antigravity needs you to sign in. "), "{error}");
|
||||
if terminal {
|
||||
assert!(error.contains(&*program.to_string_lossy()), "{error}");
|
||||
assert!(error.contains(" auth login` in a terminal."), "{error}");
|
||||
} else {
|
||||
assert!(error.contains("Sign in with the agent's own command line first."), "{error}");
|
||||
}
|
||||
assert!(error.ends_with("\n\nThis account is not eligible."), "{error}");
|
||||
assert!(error.ends_with(" login` in a terminal."), "{error}");
|
||||
mgr.shutdown();
|
||||
}
|
||||
}
|
||||
@@ -735,7 +433,6 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada
|
||||
name: "compact".into(),
|
||||
description: "Agent supplied description".into(),
|
||||
hint: Some("instructions".into()),
|
||||
source: AgentCommandSource::Command,
|
||||
};
|
||||
mgr.task(&task.id).unwrap().state.lock().task.commands = vec![advertised.clone()];
|
||||
// The task's provider wins over the caller's provider; metadata is not replaced.
|
||||
@@ -1741,12 +1438,12 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() {
|
||||
mgr.prompt(&task.id, text("late option"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
let t = mgr.task(&task.id).unwrap();
|
||||
assert!(config::bypass_approvals(&t.state.lock().task.config));
|
||||
assert!(config::bypass_approvals("cursor", &t.state.lock().task.config));
|
||||
t.kill();
|
||||
mgr.prompt(&task.id, text("Fix again"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(!agent.calls.lock().iter().any(|c| c.contains("bypass")));
|
||||
assert!(config::bypass_approvals(&mgr.shared.known_config("cursor")));
|
||||
assert!(config::bypass_approvals("cursor", &mgr.shared.known_config("cursor")));
|
||||
mgr.set_config(&task.id, config::CURSOR_APPROVAL, "ask".into()).await.unwrap();
|
||||
mgr.prompt(&task.id, text("Fix once more"), false).await.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await;
|
||||
@@ -1837,133 +1534,6 @@ async fn cursor_approval_choice_applies_before_first_prompt() {
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_full_access_mode_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "mode", "category": "mode", "type": "select", "currentValue": "ask",
|
||||
"options": [{"value": "ask", "name": "Ask"}, {"value": "full-access", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
|
||||
// Choosing Full Access while the request is already waiting also approves it.
|
||||
let waiting = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: Vec::new(),
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::NeedsInput).await;
|
||||
mgr.set_config(&waiting.id, "mode", "full-access".into()).await.unwrap();
|
||||
wait_for(&mgr, &waiting.id, AgentStatus::Ready).await;
|
||||
assert!(
|
||||
mgr.entries(&waiting.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_late_full_access_option_approves_a_pending_request() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent { hold_late_mode: true, ..Default::default() });
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("late full access"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "mode".into(), value: "full-access".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
let task = mgr.task(&task.id).unwrap();
|
||||
let (status, busy) = {
|
||||
let s = task.state.lock();
|
||||
(s.task.status, s.busy)
|
||||
};
|
||||
if status == AgentStatus::Ready && !busy {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() > deadline {
|
||||
let _ = mgr.cancel(&task.id, None);
|
||||
mgr.shutdown();
|
||||
panic!("late Full Access did not approve the pending request");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"full-access\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn an_allow_all_permissions_option_approves_tool_requests() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let agent = Arc::new(FakeAgent::default());
|
||||
*agent.extra_options.lock() = vec![json!({
|
||||
"id": "allow_all", "name": "Allow All", "category": "permissions", "type": "select", "currentValue": "off",
|
||||
"options": [{"value": "off", "name": "Ask"}, {"value": "on", "name": "Full Access"}]
|
||||
})];
|
||||
let mgr = manager(tmp.path(), agent.clone());
|
||||
let task = mgr
|
||||
.create(NewAgentTask {
|
||||
project_root: None,
|
||||
prompt: text("Fix"),
|
||||
provider: "claude".into(),
|
||||
config: vec![AgentConfigChoice { id: "allow_all".into(), value: "on".into() }],
|
||||
isolation: Isolation::Local,
|
||||
base: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
wait_for(&mgr, &task.id, AgentStatus::Ready).await;
|
||||
assert!(agent.calls.lock().iter().any(|c| c.ends_with("\"on\"")));
|
||||
assert!(
|
||||
mgr.entries(&task.id, None)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| matches!(entry, AgentEntry::Permission { chosen: Some(name), .. } if name == "Allow"))
|
||||
);
|
||||
mgr.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn archived_tasks_can_be_restored_and_continue_their_session() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -2015,7 +1585,6 @@ async fn task_project_root_controls_local_and_worktree_repositories() {
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||
};
|
||||
git(&["init", "-q", "-b", "member-main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(member.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -2329,7 +1898,6 @@ async fn delete_running_agent(no_load: bool) {
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
};
|
||||
git(&["init", "-q", "-b", "main"]);
|
||||
git(&["config", "core.autocrlf", "false"]);
|
||||
std::fs::write(root.join("file.txt"), "original\n").unwrap();
|
||||
git(&["add", "."]);
|
||||
git(&["commit", "-qm", "initial"]);
|
||||
@@ -2418,45 +1986,3 @@ async fn delete_running_agent(no_load: bool) {
|
||||
};
|
||||
assert!(!alive, "the agent (pid {pid}) is still running");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_servers_follow_server_state_and_agent_capabilities() {
|
||||
let root = std::path::Path::new("/project");
|
||||
let exe = std::path::Path::new("/app/openidea");
|
||||
assert!(super::stdio_mcp_servers(None, true, root).is_empty());
|
||||
assert!(super::stdio_mcp_servers(Some(exe), false, root).is_empty());
|
||||
let servers = super::stdio_mcp_servers(Some(exe), true, root);
|
||||
assert_eq!(
|
||||
servers,
|
||||
vec![json!({"name":"OpenIDEA","command":"/app/openidea",
|
||||
"args":["--mcp-stdio","--project","/project"],"env":[]})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn session_settings_win_over_discovery_in_memory_and_on_disk() {
|
||||
for discovery_first in [false, true] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mgr = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
let options = |model: &str, effort: &str| {
|
||||
config::options_of(&json!({ "configOptions": [
|
||||
{ "id": "model", "category": "model", "type": "select", "currentValue": model,
|
||||
"options": [{ "value": "saved" }, { "value": "discovered" }] },
|
||||
{ "id": "effort", "category": "thought_level", "type": "select", "currentValue": effort,
|
||||
"options": [{ "value": "low" }, { "value": "high" }] }
|
||||
] }))
|
||||
};
|
||||
let saved = options("saved", "low");
|
||||
let discovered = options("discovered", "high");
|
||||
if discovery_first {
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
}
|
||||
mgr.shared.remember("claude", Some(&saved), None, Some("saved"));
|
||||
mgr.shared.remember_discovered("claude", &discovered, &[]);
|
||||
let restored = manager(tmp.path(), Arc::new(FakeAgent::default()));
|
||||
for mgr in [&mgr, &restored] {
|
||||
assert_eq!(model_of(&mgr.shared.known_config("claude")).as_deref(), Some("saved"));
|
||||
assert_eq!(mgr.shared.known.lock()["claude"].thought_levels["saved"], saved[1..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,7 +95,7 @@ pub fn launch_fingerprint(
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(mut inputs) = maven::launch_inputs(root, &projects, local_repo) else { return Ok(None) };
|
||||
let plan = launch_plan(root, model, module, test, BuildKind::Build)?;
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let mut environment: BTreeMap<String, String> = std::env::vars()
|
||||
.map(|(key, value)| (key.to_ascii_uppercase(), value))
|
||||
.filter(|(key, _)| {
|
||||
|
||||
@@ -772,7 +772,13 @@ pub fn launch_has_non_java_sources(root: &Path, model: &BuildModel, module: &Pat
|
||||
has_non_java_sources(module)
|
||||
}
|
||||
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind: BuildKind) -> IdeResult<BuildPlan> {
|
||||
fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> IdeResult<BuildPlan> {
|
||||
let kind = if model.maven.as_ref().is_some_and(|m| m.projects.iter().any(|p| subdir(root, &p.dir) == module)) {
|
||||
// JDT may have left newer error stubs in Maven's output directories.
|
||||
BuildKind::Rebuild
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
@@ -784,12 +790,6 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool, kind:
|
||||
Ok(plan)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LaunchBuild {
|
||||
pub class_dirs: Vec<PathBuf>,
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// Compiles the module at `module` with its build system before a JVM launch and
|
||||
/// returns the class directories it wrote, to put ahead of the jdtls classpath.
|
||||
pub fn build_for_launch(
|
||||
@@ -797,12 +797,11 @@ pub fn build_for_launch(
|
||||
model: &BuildModel,
|
||||
module: &Path,
|
||||
test: bool,
|
||||
kind: BuildKind,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<LaunchBuild> {
|
||||
let plan = launch_plan(root, model, module, test, kind)?;
|
||||
) -> IdeResult<Vec<PathBuf>> {
|
||||
let plan = launch_plan(root, model, module, test)?;
|
||||
let result = run(root, &plan, cancel, &|_| {})?;
|
||||
let failure = if !result.success {
|
||||
if !result.success {
|
||||
let errors: Vec<String> = result
|
||||
.messages
|
||||
.iter()
|
||||
@@ -813,21 +812,17 @@ pub fn build_for_launch(
|
||||
_ => m.text.clone(),
|
||||
})
|
||||
.collect();
|
||||
Some(format!("Build failed:\n{}", errors.join("\n")))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let class_dirs = if plan.system == BuildSystem::Maven
|
||||
return Err(IdeError::internal(format!("Build failed:\n{}", errors.join("\n"))));
|
||||
}
|
||||
if plan.system == BuildSystem::Maven
|
||||
&& let Some(m) = &model.maven
|
||||
{
|
||||
maven_launch_projects(root, m, module, test)
|
||||
return Ok(maven_launch_projects(root, m, module, test)
|
||||
.iter()
|
||||
.flat_map(|p| launch_class_dirs(plan.system, &subdir(root, &p.dir), test && subdir(root, &p.dir) == module))
|
||||
.collect()
|
||||
} else {
|
||||
launch_class_dirs(plan.system, module, test)
|
||||
};
|
||||
Ok(LaunchBuild { class_dirs, failure })
|
||||
.collect());
|
||||
}
|
||||
Ok(launch_class_dirs(plan.system, module, test))
|
||||
}
|
||||
|
||||
fn launch_class_dirs(system: BuildSystem, module: &Path, test: bool) -> Vec<PathBuf> {
|
||||
@@ -1314,17 +1309,13 @@ mod tests {
|
||||
write(root, "app/pom.xml", "<project/>");
|
||||
let model = reactor_model();
|
||||
for (test, goal) in [(false, "compile"), (true, "test-compile")] {
|
||||
// A normal launch is incremental: no `clean` (IDEA's Make, not Rebuild).
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Build).unwrap();
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test).unwrap();
|
||||
let step = &plan.steps[0];
|
||||
assert_eq!(step.cwd, root);
|
||||
assert_eq!(step.program, "mvnw");
|
||||
assert_eq!(step.env["JAVA_HOME"], "/jdk");
|
||||
assert_eq!(step.args, ["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", goal]);
|
||||
// Rebuild cleans first, as IDEA's Rebuild does.
|
||||
let plan = launch_plan(root, &model, &root.join("app"), test, BuildKind::Rebuild).unwrap();
|
||||
assert_eq!(
|
||||
plan.steps[0].args,
|
||||
step.args,
|
||||
["-B", "-Dstyle.color=never", "-s", "settings.xml", "-pl", "app", "-am", "clean", goal]
|
||||
);
|
||||
}
|
||||
@@ -1346,10 +1337,9 @@ mod tests {
|
||||
std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let mut model = reactor_model();
|
||||
model.maven.as_mut().unwrap().projects[0].command = wrapper.to_string_lossy().into_owned();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap();
|
||||
let dirs = build_for_launch(root, &model, &root.join("app"), true, &CancellationToken::new()).unwrap();
|
||||
assert_eq!(
|
||||
dirs.class_dirs,
|
||||
dirs,
|
||||
["app/target/test-classes", "app/target/classes", "middle/target/classes", "lib/target/classes"]
|
||||
.map(|p| root.join(p))
|
||||
);
|
||||
@@ -1358,16 +1348,12 @@ mod tests {
|
||||
"mvnw",
|
||||
"#!/bin/sh\necho '[ERROR] Kotlin compilation failed: unresolved reference broken' >&2\nexit 1\n",
|
||||
);
|
||||
let error =
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &CancellationToken::new())
|
||||
.unwrap()
|
||||
.failure
|
||||
.unwrap();
|
||||
assert!(error.contains("Kotlin compilation failed"), "{error}");
|
||||
let error = build_for_launch(root, &model, &root.join("app"), false, &CancellationToken::new()).unwrap_err();
|
||||
assert!(error.message.contains("Kotlin compilation failed"), "{}", error.message);
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
build_for_launch(root, &model, &root.join("app"), false, BuildKind::Build, &cancel).unwrap_err().kind,
|
||||
build_for_launch(root, &model, &root.join("app"), false, &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ pub fn load_linked(
|
||||
gradle_settings: &ide_core::settings::GradleSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::has_projects(root, settings).then(|| {
|
||||
let profiles = settings.profiles(&sync::maven_profiles(cache_dir));
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let repo = maven_repo.or_else(maven::default_local_repo);
|
||||
let mut m = maven::load_with_linked(root, repo, &profiles, &settings.linked_projects);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
@@ -222,30 +222,6 @@ mod tests {
|
||||
assert!(gradle_external_roots(&root, &temp.path().join("cache"), &settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_profiles_and_checkbox_choices_control_modules_before_sync() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir(root.join("extra")).unwrap();
|
||||
std::fs::write(root.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(
|
||||
root.join("pom.xml"),
|
||||
r#"<project><artifactId>root</artifactId><packaging>pom</packaging>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles></project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let cache = root.join("cache");
|
||||
let settings = ide_core::settings::MavenSettings { active_profiles: "extra".into(), ..Default::default() };
|
||||
let modules = |settings: &ide_core::settings::MavenSettings| {
|
||||
load_linked(root, &cache, None, settings, &Default::default()).maven.unwrap().projects[0].modules.clone()
|
||||
};
|
||||
assert!(modules(&Default::default()).is_empty());
|
||||
assert_eq!(modules(&settings), ["extra/pom.xml"]);
|
||||
assert_eq!(maven::load_linked(root, &settings).projects[0].modules, ["extra/pom.xml"]);
|
||||
save_maven_profiles(&cache, &["!extra".into()]).unwrap();
|
||||
assert!(modules(&settings).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_build_systems_and_build_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -71,9 +71,6 @@ pub struct MavenModel {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProject {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
/// Project-relative, `/`-separated (`pom.xml`, `core/pom.xml`).
|
||||
pub pom: String,
|
||||
/// Project-relative directory; `""` = the project root.
|
||||
@@ -172,9 +169,6 @@ pub enum Omission {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct MavenProfile {
|
||||
/// Owning project folder when displayed in a workspace.
|
||||
#[specta(optional)]
|
||||
pub project_root: Option<String>,
|
||||
pub id: String,
|
||||
pub active_by_default: bool,
|
||||
}
|
||||
@@ -509,7 +503,7 @@ pub fn settings_profiles(settings: &Path) -> Vec<MavenProfile> {
|
||||
continue;
|
||||
}
|
||||
let by_default = profile.at("activation/activeByDefault").is_some_and(|e| e.text.trim() == "true");
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id, ..Default::default() });
|
||||
profiles.push(MavenProfile { active_by_default: by_default || active.contains(&id), id });
|
||||
}
|
||||
profiles
|
||||
}
|
||||
@@ -1069,7 +1063,7 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
|
||||
/// [`load`] with the POMs `settings` link (Add Maven Projects).
|
||||
pub fn load_linked(root: &Path, settings: &ide_core::settings::MavenSettings) -> MavenModel {
|
||||
load_with_linked(root, default_local_repo(), &settings.profiles(&[]), &settings.linked_projects)
|
||||
load_with_linked(root, default_local_repo(), &[], &settings.linked_projects)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
@@ -1218,12 +1212,11 @@ pub fn load_with_linked(
|
||||
// The project can activate the profiles of its own POM and its ancestors'.
|
||||
for (id, active) in &ctx.profiles {
|
||||
if !model.profiles.iter().any(|x| x.id == *id) {
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active, ..Default::default() });
|
||||
model.profiles.push(MavenProfile { id: id.clone(), active_by_default: *active });
|
||||
}
|
||||
}
|
||||
let artifact_id = i(&pom.artifact_id);
|
||||
model.projects.push(MavenProject {
|
||||
project_root: None,
|
||||
pom: rel_path(root, &path),
|
||||
dir: rel_path(root, &dir),
|
||||
group_id: i(props.get("project.groupId").map(String::as_str).unwrap_or_default()),
|
||||
@@ -1843,7 +1836,7 @@ mod tests {
|
||||
</settings>"#,
|
||||
)
|
||||
.unwrap();
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active, ..Default::default() };
|
||||
let p = |id: &str, active| MavenProfile { id: id.into(), active_by_default: active };
|
||||
assert_eq!(settings_profiles(&file), [p("nexus", true), p("jdk21", true), p("release", false)]);
|
||||
assert!(settings_profiles(&tmp.path().join("missing.xml")).is_empty());
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ pub fn sync_maven(
|
||||
let mut model = maven::load_with_linked(
|
||||
root,
|
||||
maven::default_local_repo(),
|
||||
&options.maven_settings.profiles(&options.profiles),
|
||||
&options.profiles,
|
||||
&options.maven_settings.linked_projects,
|
||||
);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "ide-cli"
|
||||
description = "The `openidea` command: arguments, the request it sends a running app over a local socket, and installing it on PATH"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
dirs.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
tokio = { workspace = true, features = ["net"] }
|
||||
winreg = "0.56"
|
||||
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
@@ -1,234 +0,0 @@
|
||||
//! Command-line arguments of `openidea`.
|
||||
|
||||
use crate::Target;
|
||||
use std::path::Path;
|
||||
|
||||
pub const USAGE: &str = "\
|
||||
Usage: openidea [options] [[--line <n>] [--column <n>] path[:line[:column]] ...]
|
||||
openidea diff <left> <right>
|
||||
|
||||
Opens folders as projects and files in the editor of the running OpenIDEA,
|
||||
starting it if needed. Without paths, brings OpenIDEA to the front.
|
||||
Lines and columns start at 1; --line and --column apply to the next path.
|
||||
|
||||
Options:
|
||||
--line <n> Put the caret on line <n> of the next file
|
||||
--column <n> Put the caret in column <n> of that line
|
||||
-h, --help Show this help
|
||||
-v, --version Show the version
|
||||
";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum Command {
|
||||
Help,
|
||||
Version,
|
||||
Open(Vec<Target>),
|
||||
Diff(Vec<Target>),
|
||||
}
|
||||
|
||||
/// Parses `args` (without the program name); relative paths resolve against `cwd`.
|
||||
pub fn parse(args: &[String], cwd: &Path) -> Result<Command, String> {
|
||||
parse_with(args, cwd, &|p| p.exists())
|
||||
}
|
||||
|
||||
fn parse_with(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
if args.first().is_some_and(|arg| arg == "diff") {
|
||||
return match parse_paths(&args[1..], cwd, exists)? {
|
||||
Command::Open(targets) if targets.len() == 2 => Ok(Command::Diff(targets)),
|
||||
Command::Help => Ok(Command::Help),
|
||||
_ => Err("diff needs two files".into()),
|
||||
};
|
||||
}
|
||||
parse_paths(args, cwd, exists)
|
||||
}
|
||||
|
||||
fn parse_paths(args: &[String], cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Command, String> {
|
||||
let mut targets = Vec::new();
|
||||
let mut options = true;
|
||||
let (mut line, mut column) = (None, None);
|
||||
let mut args = args.iter();
|
||||
while let Some(arg) = args.next() {
|
||||
let (flag, inline) = match arg.split_once('=') {
|
||||
Some((flag, value)) if options && flag.starts_with("--") => (flag, Some(value.to_owned())),
|
||||
_ => (arg.as_str(), None),
|
||||
};
|
||||
match flag {
|
||||
"--" if options => options = false,
|
||||
"-h" | "--help" if options => return Ok(Command::Help),
|
||||
"-v" | "--version" if options => return Ok(Command::Version),
|
||||
"--line" | "--column" if options => {
|
||||
let value = inline.or_else(|| args.next().cloned()).ok_or(format!("{flag} needs a number"))?;
|
||||
let n = position(&value).ok_or(format!("{flag}: not a number from 1: {value}"))?;
|
||||
*if flag == "--line" { &mut line } else { &mut column } = Some(n);
|
||||
}
|
||||
a if options && a.starts_with('-') && a != "-" => return Err(format!("unknown option: {a}")),
|
||||
_ => {
|
||||
let mut target = target(arg, cwd, exists)?;
|
||||
if line.is_some() || column.is_some() {
|
||||
target.line = line.take().or(target.line).or(Some(1));
|
||||
target.column = column.take();
|
||||
}
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
if line.is_some() || column.is_some() {
|
||||
return Err("--line and --column need a file after them".into());
|
||||
}
|
||||
Ok(Command::Open(targets))
|
||||
}
|
||||
|
||||
/// `arg` as a path, else as `path:line[:column]` when `path` exists.
|
||||
fn target(arg: &str, cwd: &Path, exists: &dyn Fn(&Path) -> bool) -> Result<Target, String> {
|
||||
let path = cwd.join(arg);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: None, column: None });
|
||||
}
|
||||
for (base, line, column) in split_position(arg) {
|
||||
let path = cwd.join(base);
|
||||
if exists(&path) {
|
||||
return Ok(Target { path, line: Some(line), column });
|
||||
}
|
||||
}
|
||||
Err(format!("{arg}: no such file or directory"))
|
||||
}
|
||||
|
||||
/// The readings of `arg` as `path:line:column`, then as `path:line`.
|
||||
fn split_position(arg: &str) -> Vec<(&str, u32, Option<u32>)> {
|
||||
let Some((rest, last)) = arg.rsplit_once(':') else { return Vec::new() };
|
||||
let Some(last) = position(last) else { return Vec::new() };
|
||||
let mut readings = Vec::new();
|
||||
if let Some((base, line)) = rest.rsplit_once(':')
|
||||
&& let Some(line) = position(line)
|
||||
&& !base.is_empty()
|
||||
{
|
||||
readings.push((base, line, Some(last)));
|
||||
}
|
||||
if !rest.is_empty() {
|
||||
readings.push((rest, last, None));
|
||||
}
|
||||
readings
|
||||
}
|
||||
|
||||
fn position(s: &str) -> Option<u32> {
|
||||
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
s.parse().ok().filter(|n| *n > 0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn args(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|s| (*s).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn parse_in(list: &[&str], existing: &[&str]) -> Result<Command, String> {
|
||||
let existing: Vec<PathBuf> = existing.iter().map(PathBuf::from).collect();
|
||||
parse_with(&args(list), Path::new("/work"), &|p| existing.iter().any(|e| e == p))
|
||||
}
|
||||
|
||||
fn at(path: &str, line: Option<u32>, column: Option<u32>) -> Target {
|
||||
Target { path: path.into(), line, column }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_resolve_against_cwd() {
|
||||
assert_eq!(
|
||||
parse_in(&["src", "/abs/a.rs"], &["/work/src", "/abs/a.rs"]),
|
||||
Ok(Command::Open(vec![at("/work/src", None, None), at("/abs/a.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(parse_in(&[], &[]), Ok(Command::Open(vec![])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn positions_split_only_when_the_whole_arg_is_missing() {
|
||||
let files = ["/work/a.rs"];
|
||||
assert_eq!(parse_in(&["a.rs:12"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), None)])));
|
||||
assert_eq!(parse_in(&["a.rs:12:5"], &files), Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5))])));
|
||||
assert!(parse_in(&["a.rs:0"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs:x"], &files).is_err());
|
||||
assert!(parse_in(&["missing.rs:3"], &files).is_err());
|
||||
}
|
||||
|
||||
// Windows file names cannot contain `:`, and `b:2` reads as a path on drive B.
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn an_existing_name_with_a_colon_wins_over_a_position() {
|
||||
let files = ["/work/b:2"];
|
||||
assert_eq!(parse_in(&["b:2"], &files), Ok(Command::Open(vec![at("/work/b:2", None, None)])));
|
||||
assert_eq!(parse_in(&["b:2:7"], &files), Ok(Command::Open(vec![at("/work/b:2", Some(7), None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_drive_colon_is_not_a_position() {
|
||||
assert_eq!(split_position(r"C:\a.rs:10"), vec![(r"C:\a.rs", 10, None)]);
|
||||
assert_eq!(split_position(r"C:\a.rs:10:2"), vec![(r"C:\a.rs", 10, Some(2)), (r"C:\a.rs:10", 2, None)]);
|
||||
assert!(split_position(r"C:\a.rs").is_empty());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_absolute_and_unc_targets_keep_spaces_and_unicode() {
|
||||
let local = PathBuf::from(r"C:\项目\my file.rs");
|
||||
let network = PathBuf::from(r"\\server\share\folder name");
|
||||
let command = parse_with(
|
||||
&args(&[r"C:\项目\my file.rs:12:5", r"\\server\share\folder name"]),
|
||||
Path::new(r"D:\work"),
|
||||
&|path| path == local || path == network,
|
||||
);
|
||||
assert_eq!(
|
||||
command,
|
||||
Ok(Command::Open(vec![
|
||||
Target { path: local, line: Some(12), column: Some(5) },
|
||||
Target { path: network, line: None, column: None },
|
||||
]))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_and_column_flags_apply_to_the_next_path() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["--line", "12", "--column=5", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(12), Some(5)), at("/work/b.rs", None, None)]))
|
||||
);
|
||||
assert_eq!(
|
||||
parse_in(&["--line=3", "a.rs:9:9"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(3), None)]))
|
||||
);
|
||||
assert!(parse_in(&["--line", "0", "a.rs"], &files).is_err());
|
||||
assert!(parse_in(&["--line"], &files).is_err());
|
||||
assert!(parse_in(&["a.rs", "--line", "2"], &files).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn options() {
|
||||
assert_eq!(parse_in(&["-h"], &[]), Ok(Command::Help));
|
||||
assert_eq!(parse_in(&["--version"], &[]), Ok(Command::Version));
|
||||
assert!(parse_in(&["--nope"], &[]).is_err());
|
||||
assert_eq!(parse_in(&["--", "-h"], &["/work/-h"]), Ok(Command::Open(vec![at("/work/-h", None, None)])));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn existing_diff_command_is_supported() {
|
||||
let files = ["/work/a.rs", "/work/b.rs"];
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Diff(vec![at("/work/a.rs", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert!(parse_in(&["diff", "a.rs"], &files).is_err());
|
||||
assert_eq!(
|
||||
parse_in(&["diff", "diff", "b.rs"], &["/work/diff", "/work/b.rs"]),
|
||||
Ok(Command::Diff(vec![at("/work/diff", None, None), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
assert_eq!(parse_in(&["--", "diff"], &["/work/diff"]), Ok(Command::Open(vec![at("/work/diff", None, None)])));
|
||||
assert_eq!(
|
||||
parse_in(&["--column=4", "a.rs", "b.rs"], &files),
|
||||
Ok(Command::Open(vec![at("/work/a.rs", Some(1), Some(4)), at("/work/b.rs", None, None),]))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,476 +0,0 @@
|
||||
//! Installing the `openidea` command: a symlink on `PATH` to the launcher
|
||||
//! script in a macOS bundle, or a wrapper in a user's bin directory.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const COMMAND: &str = "openidea";
|
||||
const LAUNCHER: &str = "Contents/Resources/bin/openidea";
|
||||
const SYSTEM_DIR: &str = "/usr/local/bin";
|
||||
|
||||
/// The launcher script of the bundle whose executable is `exe`; `None` outside a bundle.
|
||||
pub fn bundled_launcher(exe: &Path) -> Option<PathBuf> {
|
||||
let launcher = exe.parent()?.parent()?.parent()?.join(LAUNCHER);
|
||||
launcher.is_file().then_some(launcher)
|
||||
}
|
||||
|
||||
/// macOS runs a quarantined app from a random read-only copy until it is moved:
|
||||
/// a link there would break on the next launch.
|
||||
pub fn translocated(path: &Path) -> bool {
|
||||
path.to_string_lossy().contains("/AppTranslocation/")
|
||||
}
|
||||
|
||||
/// Where the link goes: `~/.local/bin` when it is on `path_var` (no administrator
|
||||
/// rights needed), else `/usr/local/bin`.
|
||||
pub fn link_dir(path_var: Option<&OsStr>, home: Option<&Path>) -> PathBuf {
|
||||
let local = home.map(|h| h.join(".local/bin"));
|
||||
match (path_var, local) {
|
||||
(Some(var), Some(local)) if std::env::split_paths(var).any(|p| p == local) => local,
|
||||
_ => PathBuf::from(SYSTEM_DIR),
|
||||
}
|
||||
}
|
||||
|
||||
/// Directories an earlier install may have used.
|
||||
pub fn link_dirs(home: Option<&Path>) -> Vec<PathBuf> {
|
||||
home.map(|h| h.join(".local/bin")).into_iter().chain([PathBuf::from(SYSTEM_DIR)]).collect()
|
||||
}
|
||||
|
||||
/// The installed links among `dirs` (any OpenIDEA bundle's launcher).
|
||||
pub fn installed(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
dirs.iter().map(|d| d.join(COMMAND)).filter(|l| is_launcher_link(l)).collect()
|
||||
}
|
||||
|
||||
fn is_launcher_link(link: &Path) -> bool {
|
||||
std::fs::read_link(link).is_ok_and(|target| target.ends_with(LAUNCHER))
|
||||
}
|
||||
|
||||
/// Links `dir/openidea` to `launcher`, asking for administrator rights when `dir`
|
||||
/// is not writable. Refuses to replace an `openidea` that is not ours.
|
||||
pub fn install(launcher: &Path, dir: &Path) -> io::Result<PathBuf> {
|
||||
let link = dir.join(COMMAND);
|
||||
if link.symlink_metadata().is_ok() && !is_launcher_link(&link) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", link.display()),
|
||||
));
|
||||
}
|
||||
let direct = std::fs::create_dir_all(dir).and_then(|()| {
|
||||
if link.symlink_metadata().is_ok() {
|
||||
std::fs::remove_file(&link)?;
|
||||
}
|
||||
symlink(launcher, &link)
|
||||
});
|
||||
match direct {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => {
|
||||
privileged(&format!("mkdir -p {} && ln -sfn {} {}", quote(dir), quote(launcher), quote(&link)))?
|
||||
}
|
||||
result => result?,
|
||||
}
|
||||
Ok(link)
|
||||
}
|
||||
|
||||
/// Removes `links`, asking for administrator rights where needed.
|
||||
pub fn uninstall(links: &[PathBuf]) -> io::Result<()> {
|
||||
for link in links {
|
||||
match std::fs::remove_file(link) {
|
||||
Err(e) if e.kind() == io::ErrorKind::PermissionDenied => privileged(&format!("rm -f {}", quote(link)))?,
|
||||
result => result?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn symlink(target: &Path, link: &Path) -> io::Result<()> {
|
||||
std::os::unix::fs::symlink(target, link)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn symlink(_target: &Path, _link: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(io::ErrorKind::Unsupported, "not supported on this platform"))
|
||||
}
|
||||
|
||||
/// Runs `command` as root after the system's password prompt. Cancelling it is
|
||||
/// [`io::ErrorKind::Interrupted`].
|
||||
fn privileged(command: &str) -> io::Result<()> {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return Err(io::Error::new(io::ErrorKind::PermissionDenied, "administrator rights required"));
|
||||
}
|
||||
let script = format!("do shell script \"{}\" with administrator privileges", applescript_escape(command));
|
||||
let output = std::process::Command::new("/usr/bin/osascript").arg("-e").arg(script).output()?;
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let message = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
// "User canceled. (-128)"
|
||||
let kind = if message.contains("(-128)") { io::ErrorKind::Interrupted } else { io::ErrorKind::PermissionDenied };
|
||||
Err(io::Error::new(kind, message))
|
||||
}
|
||||
|
||||
fn quote(path: &Path) -> String {
|
||||
format!("'{}'", path.to_string_lossy().replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
fn applescript_escape(s: &str) -> String {
|
||||
s.replace('\\', r"\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
const WRAPPER_MARKER: &str = "OpenIDEA command launcher v1";
|
||||
|
||||
/// AppImages remount their executable on every launch. Keep the original image path.
|
||||
pub fn stable_executable(exe: &Path, appimage: Option<&Path>) -> io::Result<PathBuf> {
|
||||
let path = appimage.unwrap_or(exe);
|
||||
if !path.is_absolute() || !path.is_file() {
|
||||
return Err(io::Error::new(io::ErrorKind::NotFound, "the OpenIDEA executable must be in a permanent location"));
|
||||
}
|
||||
Ok(path.to_path_buf())
|
||||
}
|
||||
|
||||
pub fn wrapper_path(dir: &Path, windows: bool) -> PathBuf {
|
||||
dir.join(if windows { "openidea.cmd" } else { COMMAND })
|
||||
}
|
||||
|
||||
fn wrapper(exe: &Path, windows: bool) -> io::Result<String> {
|
||||
let exe =
|
||||
exe.to_str().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the executable path is not UTF-8"))?;
|
||||
if exe.contains(['\r', '\n']) || (windows && exe.contains('"')) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "the executable path contains unsupported characters"));
|
||||
}
|
||||
Ok(if windows {
|
||||
format!(
|
||||
"@echo off\r\nrem {WRAPPER_MARKER}\r\nsetlocal DisableDelayedExpansion\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"_openidea_codepage=%%c\"\r\n\"%SystemRoot%\\System32\\chcp.com\" 65001 >nul\r\n\"{}\" --cli %*\r\nset \"_openidea_exit=%errorlevel%\"\r\n\"%SystemRoot%\\System32\\chcp.com\" %_openidea_codepage% >nul\r\nexit /b %_openidea_exit%\r\n",
|
||||
exe.replace('%', "%%")
|
||||
)
|
||||
} else {
|
||||
format!("#!/bin/sh\n# {WRAPPER_MARKER}\nexec {} --cli \"$@\"\n", quote(Path::new(exe)))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_wrapper(path: &Path, windows: bool) -> bool {
|
||||
path.symlink_metadata().is_ok_and(|meta| meta.is_file() && !meta.file_type().is_symlink())
|
||||
&& std::fs::read_to_string(path).is_ok_and(|text| {
|
||||
text.lines().nth(1) == Some(&format!("{} {WRAPPER_MARKER}", if windows { "rem" } else { "#" }))
|
||||
})
|
||||
}
|
||||
|
||||
/// Never follows or overwrites a foreign file, even if it has the same command name.
|
||||
pub fn install_wrapper(exe: &Path, dir: &Path, windows: bool) -> io::Result<PathBuf> {
|
||||
use std::io::Write;
|
||||
let text = wrapper(exe, windows)?;
|
||||
let path = wrapper_path(dir, windows);
|
||||
if path.symlink_metadata().is_ok() {
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::AlreadyExists,
|
||||
format!("{} already exists and is not an OpenIDEA command", path.display()),
|
||||
));
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
file.write_all(text.as_bytes())?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn uninstall_wrapper(dir: &Path, windows: bool) -> io::Result<Vec<PathBuf>> {
|
||||
let path = wrapper_path(dir, windows);
|
||||
if !is_wrapper(&path, windows) {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
std::fs::remove_file(&path)?;
|
||||
Ok(vec![path])
|
||||
}
|
||||
|
||||
/// Preserve every unrelated PATH entry, including expandable environment variables.
|
||||
pub fn windows_path(current: &str, dir: &str, add: bool) -> Option<String> {
|
||||
fn equal(a: &str, b: &str) -> bool {
|
||||
a.trim().trim_matches('"').trim_end_matches(['\\', '/']).eq_ignore_ascii_case(b.trim_end_matches(['\\', '/']))
|
||||
}
|
||||
let found = current.split(';').any(|entry| equal(entry, dir));
|
||||
if add {
|
||||
if found {
|
||||
return None;
|
||||
}
|
||||
Some(format!("{dir}{}{current}", if current.is_empty() { "" } else { ";" }))
|
||||
} else if found {
|
||||
Some(current.split(';').filter(|entry| !equal(entry, dir)).collect::<Vec<_>>().join(";"))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn update_user_path(dir: &Path, add: bool) -> io::Result<()> {
|
||||
use winreg::{
|
||||
RegKey,
|
||||
enums::{HKEY_CURRENT_USER, REG_EXPAND_SZ},
|
||||
types::{FromRegValue, ToRegValue},
|
||||
};
|
||||
let (environment, _) = RegKey::predef(HKEY_CURRENT_USER).create_subkey("Environment")?;
|
||||
let raw = match environment.get_raw_value("Path") {
|
||||
Ok(raw) => Some(raw),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
let current = raw.as_ref().map(String::from_reg_value).transpose()?.unwrap_or_default();
|
||||
let Some(updated) = windows_path(¤t, &dir.to_string_lossy(), add) else { return Ok(()) };
|
||||
let mut value = updated.to_reg_value();
|
||||
value.vtype = raw.as_ref().map_or(REG_EXPAND_SZ, |raw| raw.vtype.clone());
|
||||
environment.set_raw_value("Path", &value)?;
|
||||
let message: Vec<u16> = "Environment\0".encode_utf16().collect();
|
||||
// SAFETY: the terminated string stays alive for this bounded synchronous broadcast.
|
||||
unsafe {
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SendMessageTimeoutW(
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::HWND_BROADCAST,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::WM_SETTINGCHANGE,
|
||||
0,
|
||||
message.as_ptr() as isize,
|
||||
windows_sys::Win32::UI::WindowsAndMessaging::SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub struct Installation {
|
||||
pub path: PathBuf,
|
||||
/// Some when the user needs to add this directory to their shell's PATH.
|
||||
pub path_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn resolves_to(path_var: Option<&OsStr>, command: &Path) -> bool {
|
||||
path_var
|
||||
.and_then(|path| std::env::split_paths(path).map(|dir| dir.join(COMMAND)).find(|candidate| candidate.is_file()))
|
||||
.is_some_and(|candidate| candidate == command)
|
||||
}
|
||||
|
||||
fn user_bin() -> io::Result<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
dirs::data_local_dir().map(|dir| dir.join("OpenIDEA/bin"))
|
||||
} else {
|
||||
dirs::home_dir().map(|dir| dir.join(".local/bin"))
|
||||
}
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no user directory for the openidea command"))
|
||||
}
|
||||
|
||||
pub fn install_current() -> io::Result<Installation> {
|
||||
let exe = std::env::current_exe()?;
|
||||
if cfg!(target_os = "macos") {
|
||||
let launcher = bundled_launcher(&exe).ok_or_else(|| {
|
||||
io::Error::new(io::ErrorKind::NotFound, "install the command from the packaged OpenIDEA app")
|
||||
})?;
|
||||
if translocated(&launcher) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::PermissionDenied,
|
||||
"move OpenIDEA to the Applications folder first",
|
||||
));
|
||||
}
|
||||
let dir = link_dir(std::env::var_os("PATH").as_deref(), dirs::home_dir().as_deref());
|
||||
return Ok(Installation { path: install(&launcher, &dir)?, path_directory: None });
|
||||
}
|
||||
let image = if cfg!(target_os = "linux") { std::env::var_os("APPIMAGE").map(PathBuf::from) } else { None };
|
||||
let exe = stable_executable(&exe, image.as_deref())?;
|
||||
let dir = user_bin()?;
|
||||
let path = install_wrapper(&exe, &dir, cfg!(windows))?;
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, true)?;
|
||||
let on_path = cfg!(windows) || resolves_to(std::env::var_os("PATH").as_deref(), &path);
|
||||
Ok(Installation { path, path_directory: (!on_path).then_some(dir) })
|
||||
}
|
||||
|
||||
pub fn uninstall_current() -> io::Result<Vec<PathBuf>> {
|
||||
if cfg!(target_os = "macos") {
|
||||
let links = installed(&link_dirs(dirs::home_dir().as_deref()));
|
||||
uninstall(&links)?;
|
||||
return Ok(links);
|
||||
}
|
||||
let dir = user_bin()?;
|
||||
let path = wrapper_path(&dir, cfg!(windows));
|
||||
if is_wrapper(&path, cfg!(windows)) {
|
||||
#[cfg(windows)]
|
||||
update_user_path(&dir, false)?;
|
||||
return uninstall_wrapper(&dir, cfg!(windows));
|
||||
}
|
||||
Ok(vec![])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod wrapper_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn wrappers_install_update_and_remove_only_their_own_files() {
|
||||
for windows in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("App folder/程序 & it's 100%.exe");
|
||||
let path = install_wrapper(&exe, dir.path(), windows).unwrap();
|
||||
assert!(is_wrapper(&path, windows));
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap(), path);
|
||||
assert_eq!(uninstall_wrapper(dir.path(), windows).unwrap(), vec![path.clone()]);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
std::fs::write(&path, "foreign command").unwrap();
|
||||
assert_eq!(install_wrapper(&exe, dir.path(), windows).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
assert!(uninstall_wrapper(dir.path(), windows).unwrap().is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), "foreign command");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_changes_keep_other_entries_and_do_not_duplicate_the_command_dir() {
|
||||
let dir = r"C:\Users\用户\OpenIDEA\bin";
|
||||
let initial = r"%USERPROFILE%\tools;C:\other";
|
||||
let updated = windows_path(initial, dir, true).unwrap();
|
||||
assert_eq!(updated, format!("{dir};{initial}"));
|
||||
assert_eq!(windows_path(&updated, dir, true), None);
|
||||
assert_eq!(windows_path(&updated, dir, false).as_deref(), Some(initial));
|
||||
assert_eq!(
|
||||
windows_path(&format!("\"{dir}\\\";C:\\other"), &dir.to_uppercase(), false).as_deref(),
|
||||
Some(r"C:\other")
|
||||
);
|
||||
assert_eq!(windows_path(initial, dir, false), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appimage_uses_the_persistent_image_not_the_mounted_binary() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let image = dir.path().join("OpenIDEA.AppImage");
|
||||
std::fs::write(&image, "image").unwrap();
|
||||
assert_eq!(stable_executable(Path::new("/tmp/.mount_App/usr/bin/openidea"), Some(&image)).unwrap(), image);
|
||||
assert_eq!(stable_executable(&image, None).unwrap(), image);
|
||||
assert!(stable_executable(Path::new("missing"), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_a_system_binary_shadowing_the_linux_wrapper() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let system = dir.path().join("system");
|
||||
let user = dir.path().join("user");
|
||||
for bin in [&system, &user] {
|
||||
std::fs::create_dir_all(bin).unwrap();
|
||||
std::fs::write(bin.join(COMMAND), "command").unwrap();
|
||||
}
|
||||
let path = std::env::join_paths([&system, &user]).unwrap();
|
||||
assert!(!resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
let path = std::env::join_paths([&user, &system]).unwrap();
|
||||
assert!(resolves_to(Some(&path), &user.join(COMMAND)));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn posix_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("app & it's $dollar `quoted` \"double\"");
|
||||
std::fs::write(&exe, "#!/bin/sh\nprintf '%s\\n' \"$@\"\nexit 7\n").unwrap();
|
||||
std::fs::set_permissions(&exe, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), false).unwrap();
|
||||
let output = std::process::Command::new(path).args(["a b", "中文", "x'$`&"]).output().unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), "--cli\na b\n中文\nx'$`&\n");
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn cmd_wrapper_preserves_arguments_and_exit_status() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let exe = dir.path().join("程序 & 100% !special!.exe");
|
||||
let source = dir.path().join("helper.rs");
|
||||
std::fs::write(
|
||||
&source,
|
||||
r#"fn main() { for arg in std::env::args().skip(1) { println!("{arg}"); } std::process::exit(7); }"#,
|
||||
)
|
||||
.unwrap();
|
||||
let compiled = std::process::Command::new("rustc")
|
||||
.args(["--crate-name", "launcher_test"])
|
||||
.arg(&source)
|
||||
.arg("-o")
|
||||
.arg(&exe)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(compiled.status.success(), "{}", String::from_utf8_lossy(&compiled.stderr));
|
||||
let path = install_wrapper(&exe, &dir.path().join("bin"), true).unwrap();
|
||||
std::fs::write(dir.path().join("chcp.cmd"), "@echo malicious-chcp\r\nexit /b 99\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\" \"a b\" \"中文\" \"x&y\"\"", path.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap().replace("\r\n", "\n"), "--cli\na b\n中文\nx&y\n");
|
||||
let check = dir.path().join("check.cmd");
|
||||
std::fs::write(&check, "@echo off\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"before=%%c\"\r\ncall \"%~dp0bin\\openidea.cmd\" example\r\nset \"code=%errorlevel%\"\r\nfor /f \"tokens=2 delims=:\" %%c in ('\"%SystemRoot%\\System32\\chcp.com\"') do set \"after=%%c\"\r\nif not \"%before%\"==\"%after%\" exit /b 99\r\nexit /b %code%\r\n").unwrap();
|
||||
let output = std::process::Command::new("cmd.exe")
|
||||
.current_dir(dir.path())
|
||||
.args(["/d", "/s", "/c"])
|
||||
.raw_arg(format!("\"\"{}\"\"", check.display()))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(output.status.code(), Some(7));
|
||||
assert!(!String::from_utf8_lossy(&output.stdout).contains("malicious-chcp"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path) -> (PathBuf, PathBuf) {
|
||||
let app = root.join("OpenIDEA.app");
|
||||
let exe = app.join("Contents/MacOS/openidea");
|
||||
let launcher = app.join(LAUNCHER);
|
||||
std::fs::create_dir_all(exe.parent().unwrap()).unwrap();
|
||||
std::fs::create_dir_all(launcher.parent().unwrap()).unwrap();
|
||||
std::fs::write(&exe, "").unwrap();
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
(exe, launcher)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_the_launcher_of_a_bundle_only() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (exe, launcher) = bundle(dir.path());
|
||||
assert_eq!(bundled_launcher(&exe), Some(launcher));
|
||||
assert_eq!(bundled_launcher(&dir.path().join("target/debug/openidea")), None);
|
||||
assert!(translocated(Path::new("/private/var/folders/x/AppTranslocation/ABC/d/OpenIDEA.app")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_local_bin_on_path() {
|
||||
let home = Path::new("/Users/u");
|
||||
let path = std::env::join_paths(["/usr/bin", "/Users/u/.local/bin"]).unwrap();
|
||||
assert_eq!(link_dir(Some(&path), Some(home)), home.join(".local/bin"));
|
||||
assert_eq!(link_dir(Some(OsStr::new("/usr/bin")), Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
assert_eq!(link_dir(None, Some(home)), PathBuf::from(SYSTEM_DIR));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_replaces_its_own_link_but_not_other_files() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (_, launcher) = bundle(dir.path());
|
||||
let bin = dir.path().join("bin");
|
||||
let link = install(&launcher, &bin).unwrap();
|
||||
assert_eq!(std::fs::read_link(&link).unwrap(), launcher);
|
||||
assert_eq!(install(&launcher, &bin).unwrap(), link);
|
||||
assert_eq!(installed(std::slice::from_ref(&bin)), vec![link.clone()]);
|
||||
uninstall(&installed(std::slice::from_ref(&bin))).unwrap();
|
||||
assert!(installed(std::slice::from_ref(&bin)).is_empty());
|
||||
std::fs::write(&link, "someone else's").unwrap();
|
||||
assert_eq!(install(&launcher, &bin).unwrap_err().kind(), io::ErrorKind::AlreadyExists);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoting() {
|
||||
assert_eq!(quote(Path::new("/a b/it's")), r"'/a b/it'\''s'");
|
||||
assert_eq!(applescript_escape(r#"ln "a\b""#), r#"ln \"a\\b\""#);
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
//! The `openidea` command.
|
||||
//!
|
||||
//! The command is the app's own executable run in CLI mode: it parses its
|
||||
//! arguments ([`args`]), sends one [`Request`] to the running app over a local
|
||||
//! socket ([`socket`]) and exits. [`install`] links the bundle's launcher
|
||||
//! script into a directory on `PATH`.
|
||||
|
||||
pub mod args;
|
||||
pub mod install;
|
||||
pub mod socket;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Bumped when [`Request`] changes incompatibly; an app still running an older
|
||||
/// version answers with an error instead of misreading it.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// A file or folder to open, with an optional 1-based position in a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Target {
|
||||
/// Absolute.
|
||||
pub path: PathBuf,
|
||||
pub line: Option<u32>,
|
||||
pub column: Option<u32>,
|
||||
}
|
||||
|
||||
/// What the command asks the running app to do. No targets: bring the app to the front.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Request {
|
||||
pub version: u32,
|
||||
pub targets: Vec<Target>,
|
||||
#[serde(default)]
|
||||
pub diff: bool,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
pub fn open(targets: Vec<Target>) -> Self {
|
||||
Self { version: PROTOCOL_VERSION, targets, diff: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Response {
|
||||
Ok,
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
/// A GUI subsystem executable inherits redirected handles, but needs its parent's
|
||||
/// console for interactive help and errors. Batch launchers wait for this process.
|
||||
#[cfg(windows)]
|
||||
pub fn attach_console() {
|
||||
use windows_sys::Win32::Storage::FileSystem::{FILE_TYPE_DISK, FILE_TYPE_PIPE, GetFileType};
|
||||
use windows_sys::Win32::System::Console::{
|
||||
ATTACH_PARENT_PROCESS, AttachConsole, GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE,
|
||||
SetStdHandle,
|
||||
};
|
||||
// SAFETY: attaches only this CLI process, without allocating a new console.
|
||||
unsafe {
|
||||
let redirected = [STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, STD_ERROR_HANDLE].map(|kind| {
|
||||
let handle = GetStdHandle(kind);
|
||||
(kind, handle, matches!(GetFileType(handle), FILE_TYPE_DISK | FILE_TYPE_PIPE))
|
||||
});
|
||||
AttachConsole(ATTACH_PARENT_PROCESS);
|
||||
for (kind, handle, keep) in redirected {
|
||||
if keep {
|
||||
SetStdHandle(kind, handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
//! The local socket the running app listens on: one JSON [`Request`] line in,
|
||||
//! one [`Response`] line out. Unix domain sockets or Windows named pipes.
|
||||
|
||||
use crate::{Request, Response};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_MESSAGE: u64 = 1024 * 1024;
|
||||
|
||||
fn answer(line: &str, handle: &dyn Fn(Request) -> Response) -> Response {
|
||||
match serde_json::from_str::<Request>(line) {
|
||||
Ok(request) if request.version == crate::PROTOCOL_VERSION && (!request.diff || request.targets.len() == 2) => {
|
||||
handle(request)
|
||||
}
|
||||
Ok(_) => Response::Error { message: "unsupported or invalid CLI request".into() },
|
||||
Err(e) => Response::Error { message: format!("invalid request: {e}") },
|
||||
}
|
||||
}
|
||||
|
||||
/// The socket of the app with bundle identifier `identifier`. The name is a hash:
|
||||
/// socket paths are limited to about 100 bytes, and development identifiers are long.
|
||||
pub fn socket_path(identifier: &str) -> Option<PathBuf> {
|
||||
if cfg!(windows) {
|
||||
let user = dirs::data_local_dir()?;
|
||||
return Some(PathBuf::from(format!(
|
||||
r"\\.\pipe\openidea-{:016x}",
|
||||
fnv1a(&format!("{}:{identifier}", user.display()))
|
||||
)));
|
||||
}
|
||||
let dir = if cfg!(target_os = "macos") { dirs::cache_dir() } else { dirs::runtime_dir().or_else(dirs::cache_dir) };
|
||||
Some(dir?.join(format!("openidea-{:016x}.sock", fnv1a(identifier))))
|
||||
}
|
||||
|
||||
fn fnv1a(s: &str) -> u64 {
|
||||
s.bytes().fold(0xcbf29ce484222325, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100000001b3))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
|
||||
use std::os::unix::net::{UnixListener, UnixStream};
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
let stream = UnixStream::connect(socket)?;
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
write_line(&stream, request)?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&stream).take(MAX_MESSAGE)).read_line(&mut reply)?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let listener = bind(socket)?;
|
||||
std::thread::Builder::new().name("cli-socket".into()).spawn(move || {
|
||||
for stream in listener.incoming().flatten() {
|
||||
if let Err(e) = serve_one(&stream, &handle) {
|
||||
tracing::warn!("cli request failed: {e}");
|
||||
}
|
||||
}
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Binds `socket`, replacing a stale file no one answers on.
|
||||
fn bind(socket: &Path) -> io::Result<UnixListener> {
|
||||
if let Some(dir) = socket.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
let listener = match UnixListener::bind(socket) {
|
||||
Err(e) if e.kind() == io::ErrorKind::AddrInUse => {
|
||||
if !std::fs::symlink_metadata(socket)?.file_type().is_socket() || UnixStream::connect(socket).is_ok() {
|
||||
return Err(e);
|
||||
}
|
||||
std::fs::remove_file(socket)?;
|
||||
UnixListener::bind(socket)?
|
||||
}
|
||||
bound => bound?,
|
||||
};
|
||||
std::fs::set_permissions(socket, std::fs::Permissions::from_mode(0o600))?;
|
||||
Ok(listener)
|
||||
}
|
||||
|
||||
fn serve_one(stream: &UnixStream, handle: &dyn Fn(Request) -> Response) -> io::Result<()> {
|
||||
stream.set_read_timeout(Some(TIMEOUT))?;
|
||||
stream.set_write_timeout(Some(TIMEOUT))?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream.take(MAX_MESSAGE)).read_line(&mut line)?;
|
||||
write_line(stream, &answer(&line, handle))
|
||||
}
|
||||
|
||||
fn write_line(mut stream: &UnixStream, value: &impl serde::Serialize) -> io::Result<()> {
|
||||
let mut line = serde_json::to_string(value)?;
|
||||
line.push('\n');
|
||||
stream.write_all(line.as_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
mod imp {
|
||||
use super::*;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{ClientOptions, ServerOptions};
|
||||
|
||||
fn runtime() -> io::Result<tokio::runtime::Runtime> {
|
||||
tokio::runtime::Builder::new_current_thread().enable_all().build()
|
||||
}
|
||||
|
||||
pub fn send(socket: &Path, request: &Request) -> io::Result<Response> {
|
||||
runtime()?.block_on(async {
|
||||
tokio::time::timeout(TIMEOUT, async {
|
||||
let mut pipe = loop {
|
||||
match ClientOptions::new().open(socket) {
|
||||
Ok(pipe) => break pipe,
|
||||
// All existing instances are busy; wait instead of starting another app.
|
||||
Err(e) if e.raw_os_error() == Some(231) => tokio::time::sleep(Duration::from_millis(10)).await,
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
let mut line = serde_json::to_vec(request)?;
|
||||
line.push(b'\n');
|
||||
pipe.write_all(&line).await?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut reply).await?;
|
||||
pipe.write_all(b"\n").await?;
|
||||
Ok(serde_json::from_str(&reply)?)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::TimedOut, "CLI request timed out"))?
|
||||
})
|
||||
}
|
||||
|
||||
pub fn serve(socket: &Path, handle: impl Fn(Request) -> Response + Send + 'static) -> io::Result<()> {
|
||||
let socket = socket.to_owned();
|
||||
let (ready, receiver) = std::sync::mpsc::sync_channel(1);
|
||||
std::thread::Builder::new().name("cli-pipe".into()).spawn(move || {
|
||||
let runtime = match runtime() {
|
||||
Ok(runtime) => runtime,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
runtime.block_on(async move {
|
||||
let mut pipe = match ServerOptions::new().first_pipe_instance(true).create(&socket) {
|
||||
Ok(pipe) => pipe,
|
||||
Err(e) => {
|
||||
let _ = ready.send(Err(e));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = ready.send(Ok(()));
|
||||
loop {
|
||||
if let Err(e) = pipe.connect().await {
|
||||
tracing::warn!("CLI pipe connection failed: {e}");
|
||||
return;
|
||||
}
|
||||
// Keep an instance bound while answering, so another process cannot take the name.
|
||||
let next = match ServerOptions::new().create(&socket) {
|
||||
Ok(next) => next,
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot create CLI pipe: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let reply = tokio::time::timeout(TIMEOUT, async {
|
||||
let mut line = String::new();
|
||||
BufReader::new((&mut pipe).take(MAX_MESSAGE)).read_line(&mut line).await?;
|
||||
let mut reply = serde_json::to_vec(&answer(&line, &handle))?;
|
||||
reply.push(b'\n');
|
||||
pipe.write_all(&reply).await?;
|
||||
// Wait until the client has drained the reply before closing its instance.
|
||||
pipe.read_exact(&mut [0u8; 1]).await.map(|_| ())
|
||||
})
|
||||
.await;
|
||||
if !matches!(reply, Ok(Ok(()))) {
|
||||
tracing::warn!("CLI pipe request failed: {reply:?}");
|
||||
}
|
||||
// Dropping after the write lets the client drain its reply before disconnecting.
|
||||
drop(pipe);
|
||||
pipe = next;
|
||||
}
|
||||
});
|
||||
})?;
|
||||
receiver.recv().map_err(|_| io::Error::other("CLI pipe thread stopped"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends `request` to the app listening on `socket` and waits for its answer.
|
||||
pub use imp::send;
|
||||
/// Answers requests on `socket` with `handle`, on a thread of its own.
|
||||
pub use imp::serve;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn socket_names_separate_identifiers_and_stay_short() {
|
||||
let release = socket_path("ai.l0ng.openidea").unwrap();
|
||||
let dev = socket_path("ai.l0ng.openidea.dev.w158e9c5954b7bf64").unwrap();
|
||||
assert_ne!(release, dev);
|
||||
assert!(dev.as_os_str().len() < 100 || cfg!(unix));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn answers_only_its_protocol_version() {
|
||||
let ok = |_: Request| Response::Ok;
|
||||
assert_eq!(answer(&serde_json::to_string(&Request::open(vec![])).unwrap(), &ok), Response::Ok);
|
||||
let old = r#"{"version":0,"targets":[]}"#;
|
||||
assert!(matches!(answer(old, &ok), Response::Error { .. }));
|
||||
assert!(matches!(answer("garbage", &ok), Response::Error { .. }));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn named_pipe_round_trip() {
|
||||
let socket = socket_path(&format!("test-{}", std::process::id())).unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
for _ in 0..2 {
|
||||
let target = crate::Target { path: r"C:\项目\my file.rs".into(), line: Some(3), column: Some(2) };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv_timeout(TIMEOUT).unwrap().targets, vec![target]);
|
||||
}
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn request_round_trip_and_stale_socket() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
// A file left by an app that exited without removing it.
|
||||
drop(std::os::unix::net::UnixListener::bind(&socket).unwrap());
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
serve(&socket, move |request| {
|
||||
tx.send(request).unwrap();
|
||||
Response::Ok
|
||||
})
|
||||
.unwrap();
|
||||
let target = crate::Target { path: "/p/a.rs".into(), line: Some(3), column: None };
|
||||
assert_eq!(send(&socket, &Request::open(vec![target.clone()])).unwrap(), Response::Ok);
|
||||
assert_eq!(rx.recv().unwrap().targets, vec![target]);
|
||||
// A live server keeps its socket.
|
||||
assert_eq!(serve(&socket, |_| Response::Ok).unwrap_err().kind(), io::ErrorKind::AddrInUse);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn refuses_to_remove_an_unrelated_file() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let socket = dir.path().join("cli.sock");
|
||||
std::fs::write(&socket, "keep").unwrap();
|
||||
assert!(serve(&socket, |_| Response::Ok).is_err());
|
||||
assert_eq!(std::fs::read_to_string(&socket).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -22,4 +22,4 @@ tempfile.workspace = true
|
||||
libc = "0.2"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Wdk_System_SystemServices", "Win32_Foundation", "Win32_Globalization", "Win32_Security", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_Threading"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_Security", "Win32_System_Registry", "Win32_Foundation", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Threading"] }
|
||||
@@ -10,25 +10,9 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum McpUiRequest {
|
||||
Confirm { client: String, tool: String, subject: String, project: String, detail: Option<String> },
|
||||
OpenFile { path: crate::IpcPath, line: Option<u32>, column: Option<u32> },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
||||
pub enum IdeEvent {
|
||||
/// Project data source settings changed; reload the Database tree.
|
||||
DbSourcesChanged,
|
||||
McpRequest {
|
||||
id: u32,
|
||||
request: McpUiRequest,
|
||||
},
|
||||
McpRequestClosed {
|
||||
id: u32,
|
||||
},
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
/// A window entered or left full screen, itself or as a member of a full-screen window group.
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
//! Bounded, ordered process input. Only the worker touches the OS writer.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{self, Write};
|
||||
use std::sync::{Arc, Condvar, Mutex};
|
||||
use std::thread::JoinHandle;
|
||||
#[cfg(any(not(unix), test))]
|
||||
use std::time::Duration;
|
||||
|
||||
const MAX_BYTES: usize = 4 * 1024 * 1024;
|
||||
const MAX_WRITES: usize = 1024;
|
||||
#[cfg(any(not(unix), test))]
|
||||
const RETRY: Duration = Duration::from_millis(4);
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
queue: VecDeque<Vec<u8>>,
|
||||
bytes: usize,
|
||||
finished: bool,
|
||||
aborted: bool,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// One worker per pipe/PTY. The byte budget includes the in-flight write.
|
||||
/// Rejected writes enqueue nothing. Dropping this handle discards pending input
|
||||
/// without joining the worker; `finish` instead drains accepted input before EOF.
|
||||
pub struct InputWriter {
|
||||
shared: Arc<(Mutex<State>, Condvar)>,
|
||||
worker: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl InputWriter {
|
||||
/// On Unix, pass the readiness hook returned by `nonblocking` for this writer.
|
||||
pub fn new(
|
||||
name: String,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
#[cfg(unix)] mut wait_writable: impl FnMut() -> io::Result<()> + Send + 'static,
|
||||
on_error: impl FnOnce(io::Error) + Send + 'static,
|
||||
) -> io::Result<Self> {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let worker_shared = shared.clone();
|
||||
let worker = std::thread::Builder::new().name(name).spawn(move || {
|
||||
let (lock, wake) = &*worker_shared;
|
||||
let result = (|| {
|
||||
loop {
|
||||
let data = {
|
||||
let mut state = lock.lock().unwrap();
|
||||
while state.queue.is_empty() && !state.finished && !state.aborted {
|
||||
state = wake.wait(state).unwrap();
|
||||
}
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(data) = state.queue.pop_front() else { return Ok(()) };
|
||||
data
|
||||
};
|
||||
let mut offset = 0;
|
||||
loop {
|
||||
if lock.lock().unwrap().aborted {
|
||||
return Ok(());
|
||||
}
|
||||
let flushing = offset == data.len();
|
||||
let result = if !flushing {
|
||||
writer.write(&data[offset..]).and_then(|n| {
|
||||
if n == 0 {
|
||||
Err(io::ErrorKind::WriteZero.into())
|
||||
} else {
|
||||
offset += n;
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
} else {
|
||||
writer.flush()
|
||||
};
|
||||
match result {
|
||||
Ok(()) if flushing => break,
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
match wait_writable() {
|
||||
Err(e) if e.kind() == io::ErrorKind::Interrupted => {}
|
||||
result => result?,
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return Ok(());
|
||||
}
|
||||
drop(wake.wait_timeout(state, RETRY).unwrap());
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
let len = data.len();
|
||||
drop(data);
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.bytes = state.bytes.saturating_sub(len);
|
||||
}
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
let mut state = lock.lock().unwrap();
|
||||
if state.aborted {
|
||||
return;
|
||||
}
|
||||
state.error = Some(e.to_string());
|
||||
state.queue.clear();
|
||||
state.bytes = 0;
|
||||
drop(state);
|
||||
on_error(e);
|
||||
}
|
||||
})?;
|
||||
Ok(Self { shared, worker: Some(worker) })
|
||||
}
|
||||
|
||||
pub fn write(&self, data: &[u8]) -> io::Result<()> {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
if let Some(error) = &state.error {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, error.clone()));
|
||||
}
|
||||
if state.finished || state.aborted {
|
||||
return Err(io::Error::new(io::ErrorKind::BrokenPipe, "stdin is closed"));
|
||||
}
|
||||
if data.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if data.len() > MAX_BYTES.saturating_sub(state.bytes) || state.queue.len() >= MAX_WRITES {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::WouldBlock,
|
||||
"Input queue is full (4 MiB or 1024 writes); input was not sent.",
|
||||
));
|
||||
}
|
||||
state.bytes += data.len();
|
||||
state.queue.push_back(data.to_vec());
|
||||
wake.notify_one();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send EOF after the accepted writes, without waiting for the child to read.
|
||||
pub fn finish(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
lock.lock().unwrap().finished = true;
|
||||
wake.notify_one();
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
let (lock, wake) = &*self.shared;
|
||||
let mut state = lock.lock().unwrap();
|
||||
state.aborted = true;
|
||||
state.queue.clear();
|
||||
wake.notify_one();
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for InputWriter {
|
||||
fn drop(&mut self) {
|
||||
self.close();
|
||||
let Some(worker) = self.worker.take() else { return };
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
// A cancellation can race with entry into WriteFile, so retry off
|
||||
// the caller's thread until the worker observes the closed queue.
|
||||
let _ = std::thread::Builder::new().name("openidea-input-cancel".into()).spawn(move || {
|
||||
while !worker.is_finished() {
|
||||
unsafe {
|
||||
windows_sys::Win32::System::IO::CancelSynchronousIo(worker.as_raw_handle());
|
||||
}
|
||||
std::thread::sleep(RETRY);
|
||||
}
|
||||
let _ = worker.join();
|
||||
});
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
drop(worker);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make a writer nonblocking and return its readiness hook. PTY clones share file
|
||||
/// status flags, so readers must also handle WouldBlock.
|
||||
#[cfg(unix)]
|
||||
pub fn nonblocking(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
let wait = readiness(fd, libc::POLLOUT)?;
|
||||
let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
|
||||
if flags == -1 || unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(wait)
|
||||
}
|
||||
|
||||
/// Wait for output from a nonblocking PTY without delaying the child's next read.
|
||||
#[cfg(unix)]
|
||||
pub fn readable(fd: std::os::fd::RawFd) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
readiness(fd, libc::POLLIN)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn readiness(fd: std::os::fd::RawFd, events: libc::c_short) -> io::Result<impl FnMut() -> io::Result<()> + Send> {
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
// Own a duplicate so the hook remains valid if the caller closes its fd.
|
||||
let duplicate = unsafe { libc::fcntl(fd, libc::F_DUPFD_CLOEXEC, 0) };
|
||||
if duplicate == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: fcntl returned a new, owned descriptor.
|
||||
let fd = unsafe { OwnedFd::from_raw_fd(duplicate) };
|
||||
Ok(move || {
|
||||
let mut pollfd = libc::pollfd { fd: fd.as_raw_fd(), events, revents: 0 };
|
||||
// Readiness wakes immediately; the timeout only bounds abort latency.
|
||||
if unsafe { libc::poll(&mut pollfd, 1, 50) } == -1 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if pollfd.revents & libc::POLLNVAL != 0 {
|
||||
return Err(io::Error::from_raw_os_error(libc::EBADF));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc;
|
||||
#[cfg(unix)]
|
||||
use std::time::Instant;
|
||||
|
||||
struct SlowWriter {
|
||||
ready: Arc<AtomicBool>,
|
||||
output: Arc<Mutex<Vec<u8>>>,
|
||||
dropped: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl Write for SlowWriter {
|
||||
fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
|
||||
if !self.ready.load(Ordering::Acquire) {
|
||||
return Err(io::ErrorKind::WouldBlock.into());
|
||||
}
|
||||
let n = bytes.len().min(7);
|
||||
self.output.lock().unwrap().extend_from_slice(&bytes[..n]);
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for SlowWriter {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.dropped.send(());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_include_inflight_bytes_and_reject_whole_writes() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
std::thread::sleep(RETRY * 2);
|
||||
assert_eq!(writer.write(b"rejected").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
writer.close();
|
||||
rx.recv_timeout(Duration::from_secs(1)).expect("worker stops while peer still refuses input");
|
||||
assert!(output.lock().unwrap().is_empty());
|
||||
assert!(writer.write(b"closed").is_err());
|
||||
assert!(writer.shared.0.lock().unwrap().queue.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_writes_preserve_order_and_finish_drains_before_eof() {
|
||||
let ready = Arc::new(AtomicBool::new(false));
|
||||
let output = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-input".into(),
|
||||
Box::new(SlowWriter { ready: ready.clone(), output: output.clone(), dropped: tx }),
|
||||
#[cfg(unix)]
|
||||
|| {
|
||||
std::thread::sleep(RETRY);
|
||||
Ok(())
|
||||
},
|
||||
|e| panic!("{e}"),
|
||||
)
|
||||
.unwrap();
|
||||
let paste = "abé".repeat(1024);
|
||||
writer.write(paste.as_bytes()).unwrap();
|
||||
writer.write(b"tail").unwrap();
|
||||
writer.finish();
|
||||
assert!(writer.write(b"after EOF").is_err());
|
||||
ready.store(true, Ordering::Release);
|
||||
rx.recv_timeout(Duration::from_secs(1)).unwrap();
|
||||
assert_eq!(*output.lock().unwrap(), format!("{paste}tail").as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn readiness_errors_are_reported_and_reject_following_writes() {
|
||||
let (dropped, _rx) = mpsc::channel();
|
||||
let (errors, error) = mpsc::channel();
|
||||
let writer = InputWriter::new(
|
||||
"test-error-input".into(),
|
||||
Box::new(SlowWriter { ready: Arc::new(AtomicBool::new(false)), output: Arc::default(), dropped }),
|
||||
|| Err(io::Error::from_raw_os_error(libc::EBADF)),
|
||||
move |e| errors.send(e.raw_os_error()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
writer.write(b"input").unwrap();
|
||||
assert_eq!(error.recv_timeout(Duration::from_secs(1)).unwrap(), Some(libc::EBADF));
|
||||
assert_eq!(writer.write(b"later").unwrap_err().kind(), io::ErrorKind::BrokenPipe);
|
||||
let state = writer.shared.0.lock().unwrap();
|
||||
assert!(state.queue.is_empty());
|
||||
assert_eq!(state.bytes, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiny_writes_are_also_bounded() {
|
||||
let shared = Arc::new((Mutex::new(State::default()), Condvar::new()));
|
||||
let writer = InputWriter { shared, worker: None };
|
||||
for _ in 0..MAX_WRITES {
|
||||
writer.write(b"x").unwrap();
|
||||
}
|
||||
assert_eq!(writer.write(b"x").unwrap_err().kind(), io::ErrorKind::WouldBlock);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn writable_input_resumes_without_timer_delays() {
|
||||
use std::io::Read;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (pipe, mut peer) = UnixStream::pair().unwrap();
|
||||
let capacity: libc::c_int = 1024;
|
||||
// Keep the OS buffer small enough that a timer per buffer takes seconds.
|
||||
assert_eq!(
|
||||
unsafe {
|
||||
libc::setsockopt(
|
||||
pipe.as_raw_fd(),
|
||||
libc::SOL_SOCKET,
|
||||
libc::SO_SNDBUF,
|
||||
(&capacity as *const libc::c_int).cast(),
|
||||
std::mem::size_of_val(&capacity) as libc::socklen_t,
|
||||
)
|
||||
},
|
||||
0
|
||||
);
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let writer =
|
||||
InputWriter::new("test-ready-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
let bytes = vec![b'x'; MAX_BYTES];
|
||||
writer.write(&bytes).unwrap();
|
||||
writer.finish();
|
||||
// Start with a full buffer, then make the fd writable from another thread.
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let started = Instant::now();
|
||||
let reader = std::thread::spawn(move || {
|
||||
let mut output = Vec::new();
|
||||
peer.read_to_end(&mut output).unwrap();
|
||||
tx.send(output).unwrap();
|
||||
});
|
||||
let output = rx.recv_timeout(Duration::from_secs(1)).expect("input resumes as soon as the fd is writable");
|
||||
assert_eq!(output, bytes);
|
||||
reader.join().unwrap();
|
||||
eprintln!("4 MiB input drained in {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn close_interrupts_a_full_os_pipe_while_the_peer_stays_open() {
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
let (pipe, _peer) = UnixStream::pair().unwrap();
|
||||
let wait_writable = nonblocking(pipe.as_raw_fd()).unwrap();
|
||||
let mut writer =
|
||||
InputWriter::new("test-pipe-input".into(), Box::new(pipe), wait_writable, |e| panic!("{e}")).unwrap();
|
||||
writer.write(&vec![b'x'; MAX_BYTES]).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
assert!(!writer.worker.as_ref().unwrap().is_finished());
|
||||
let started = Instant::now();
|
||||
writer.close();
|
||||
writer.worker.take().unwrap().join().unwrap();
|
||||
assert!(started.elapsed() < Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,11 @@ pub mod event;
|
||||
pub mod go;
|
||||
pub mod ids;
|
||||
pub mod ignored;
|
||||
pub mod input;
|
||||
pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod java_runtime;
|
||||
pub mod jsonc;
|
||||
pub mod output_encoding;
|
||||
pub mod platform;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod project_roots;
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
/// The Windows OS build used by ConPTY; unavailable on other platforms or on query failure.
|
||||
#[cfg(windows)]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
use windows_sys::Wdk::System::SystemServices::RtlGetVersion;
|
||||
use windows_sys::Win32::System::SystemInformation::OSVERSIONINFOW;
|
||||
|
||||
let mut info =
|
||||
OSVERSIONINFOW { dwOSVersionInfoSize: std::mem::size_of::<OSVERSIONINFOW>() as u32, ..Default::default() };
|
||||
// SAFETY: `info` is initialized and declares the size of the writable structure.
|
||||
let status = unsafe { RtlGetVersion(&mut info) };
|
||||
(status >= 0 && info.dwBuildNumber > 0).then_some(info.dwBuildNumber)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn windows_build_number() -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn windows_build_is_platform_specific() {
|
||||
let build = super::windows_build_number();
|
||||
#[cfg(windows)]
|
||||
assert!(build.is_some_and(|build| build > 0));
|
||||
#[cfg(not(windows))]
|
||||
assert_eq!(build, None);
|
||||
}
|
||||
}
|
||||
@@ -110,12 +110,6 @@ const TREE_EXIT_WAIT: Duration = Duration::from_secs(5);
|
||||
#[cfg(windows)]
|
||||
fn taskkill_tree_of(pid: u32) {
|
||||
let started = windows_tree::descendants(pid);
|
||||
// When the child started nothing, killing it alone is the whole job:
|
||||
// `taskkill` would be a process spawn (measured at ~0.7 s here) for the
|
||||
// same outcome, and the callers kill the child themselves.
|
||||
if started.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut taskkill = Command::new("taskkill");
|
||||
prepare(&mut taskkill)
|
||||
.args(["/T", "/F", "/PID", &pid.to_string()])
|
||||
|
||||
@@ -191,19 +191,6 @@ pub struct RunTarget {
|
||||
pub image_user: bool,
|
||||
}
|
||||
|
||||
/// A plugin-owned argv wrapper for project runs (API 1.4).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginRunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub workdir_args: Vec<String>,
|
||||
pub container_root: Option<String>,
|
||||
pub commands: Vec<(String, Vec<String>)>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -61,8 +61,6 @@ pub struct Settings {
|
||||
pub cmake: CMakeSettings,
|
||||
/// Tools › Agents › Chat.
|
||||
pub agents: AgentSettings,
|
||||
/// Tools › MCP Server (global only).
|
||||
pub mcp_server: McpServerSettings,
|
||||
/// Plugin settings (`[contributes.settings]`): plugin id → its values by key.
|
||||
#[specta(type = BTreeMap<String, BTreeMap<String, specta_typescript::Unknown>>)]
|
||||
pub plugins: BTreeMap<String, serde_json::Map<String, serde_json::Value>>,
|
||||
@@ -88,14 +86,6 @@ impl Default for AgentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tools › MCP Server: agents use the IDE's built-in tools.
|
||||
#[derive(Clone, Debug, Default, 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.
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
/// Absent profiles retain automatic discovery for existing projects.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -240,7 +230,6 @@ impl Default for Settings {
|
||||
run_targets: Vec::new(),
|
||||
cmake: CMakeSettings::default(),
|
||||
agents: AgentSettings::default(),
|
||||
mcp_server: McpServerSettings::default(),
|
||||
plugins: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
@@ -386,8 +375,6 @@ pub struct AppearanceSettings {
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// A lone Alt press focuses or opens the main menu (IDEA `ide.windows.main.menu.focus.on.alt`).
|
||||
pub main_menu_alt: bool,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -395,9 +382,6 @@ pub struct AppearanceSettings {
|
||||
pub show_tool_window_names: bool,
|
||||
pub show_tool_window_badges: bool,
|
||||
pub widescreen_tool_windows: bool,
|
||||
/// View › Appearance › Toolbar; hidden leaves IDEA's compact window header.
|
||||
pub show_main_toolbar: bool,
|
||||
pub show_status_bar: bool,
|
||||
/// Status bar widgets shown (`true`) or hidden (`false`) by the user
|
||||
/// (status bar context menu); widgets not listed use their default.
|
||||
pub status_bar_widgets: BTreeMap<String, bool>,
|
||||
@@ -461,15 +445,12 @@ impl Default for AppearanceSettings {
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
main_menu_alt: true,
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
show_tool_window_names: false,
|
||||
show_tool_window_badges: true,
|
||||
widescreen_tool_windows: false,
|
||||
show_main_toolbar: true,
|
||||
show_status_bar: true,
|
||||
status_bar_widgets: BTreeMap::new(),
|
||||
main_toolbar: None,
|
||||
}
|
||||
@@ -629,8 +610,6 @@ impl Default for FileSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguagesSettings {
|
||||
/// Explicit plugin formatter, by language id. Empty entries use the normal formatter policy.
|
||||
pub plugin_formatters: BTreeMap<String, String>,
|
||||
pub python: PythonSettings,
|
||||
pub node: NodeSettings,
|
||||
pub php: PhpSettings,
|
||||
@@ -648,7 +627,6 @@ pub struct LanguagesSettings {
|
||||
impl Default for LanguagesSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
plugin_formatters: BTreeMap::new(),
|
||||
python: PythonSettings::default(),
|
||||
node: NodeSettings::default(),
|
||||
php: PhpSettings::default(),
|
||||
@@ -770,8 +748,6 @@ impl Default for PythonSettings {
|
||||
pub struct PhpSettings {
|
||||
/// `php` CLI executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
@@ -799,8 +775,6 @@ pub struct RubySettings {
|
||||
pub struct NodeSettings {
|
||||
/// `node` executable; empty = the one on `PATH`.
|
||||
pub interpreter: String,
|
||||
/// Run PHP/Composer or Node/package-manager commands in this target; empty = project default, `local` = local.
|
||||
pub run_target: String,
|
||||
/// `npm` | `pnpm` | `yarn`; empty = detect from the lock file / `packageManager`.
|
||||
pub package_manager: String,
|
||||
}
|
||||
@@ -941,8 +915,6 @@ pub struct MavenSettings {
|
||||
pub home: String,
|
||||
/// User `settings.xml` (`-s`); empty = `~/.m2/settings.xml`.
|
||||
pub user_settings: String,
|
||||
/// Toolchains file (`-t`); empty = `~/.m2/toolchains.xml`.
|
||||
pub toolchains: String,
|
||||
/// Local repository (`-Dmaven.repo.local`); empty = from `settings.xml`.
|
||||
pub local_repository: String,
|
||||
/// Run Maven through the Maven Daemon (`mvnd`) for faster builds. mvnd embeds
|
||||
@@ -954,25 +926,9 @@ pub struct MavenSettings {
|
||||
/// Thread count of Maven builds (`-T`): a number of threads (`4`) or a core
|
||||
/// multiplier (`2C`, `2.5C`); empty = Maven's default (one thread per module).
|
||||
pub threads: String,
|
||||
/// Active Maven profiles (`-P`), comma-separated; empty leaves profiles to the POM/settings.
|
||||
pub active_profiles: String,
|
||||
}
|
||||
|
||||
impl MavenSettings {
|
||||
/// Settings profiles followed by the Maven tool window's choices.
|
||||
pub fn profiles(&self, choices: &[String]) -> Vec<String> {
|
||||
let mut profiles = Vec::new();
|
||||
for profile in self.active_profiles.split(',').chain(choices.iter().map(String::as_str)).map(str::trim) {
|
||||
if !profile.is_empty() && !profiles.iter().any(|p| p == profile) {
|
||||
profiles.push(profile.to_owned());
|
||||
}
|
||||
}
|
||||
let excluded: Vec<String> =
|
||||
profiles.iter().filter_map(|p| p.strip_prefix(['!', '-']).map(str::to_owned)).collect();
|
||||
profiles.retain(|p| !excluded.contains(p));
|
||||
profiles
|
||||
}
|
||||
|
||||
/// The `-T` value when it is one Maven accepts: a thread count (`4`) or a
|
||||
/// core multiplier with an optional decimal fraction (`1C`, `2.5C`).
|
||||
/// Anything else is dropped: a bad `-T` would make every Maven run fail.
|
||||
@@ -1263,13 +1219,9 @@ mod tests {
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
assert!(old.appearance.main_menu_alt);
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
let off: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuAlt":false}}"#).unwrap();
|
||||
assert!(!off.appearance.main_menu_alt);
|
||||
assert_eq!(serde_json::to_value(off).unwrap()["appearance"]["mainMenuAlt"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1436,24 +1388,6 @@ mod maven_linkage_tests {
|
||||
assert_eq!(threads("2..5C").as_deref(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_combine_settings_and_tool_window_choices() {
|
||||
let settings = MavenSettings { active_profiles: " a, b, ,a ".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["!b".into(), "c".into(), "a".into()]), ["a", "!b", "c"]);
|
||||
let settings = MavenSettings { active_profiles: "!a".into(), ..Default::default() };
|
||||
assert_eq!(settings.profiles(&["a".into()]), ["!a"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_profiles_and_toolchains_round_trip_and_default_to_empty() {
|
||||
let old: MavenSettings = serde_json::from_str(r#"{"home":"/opt/maven"}"#).unwrap();
|
||||
assert!(old.toolchains.is_empty() && old.active_profiles.is_empty());
|
||||
let settings =
|
||||
MavenSettings { toolchains: "config files/toolchains.xml".into(), active_profiles: "a,b,!c".into(), ..old };
|
||||
let encoded = serde_json::to_string(&settings).unwrap();
|
||||
assert_eq!(serde_json::from_str::<MavenSettings>(&encoded).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_count_round_trips_and_defaults_to_empty() {
|
||||
// Old settings files predate the thread count; `serde(default)` keeps them loadable.
|
||||
|
||||
@@ -272,8 +272,6 @@ impl Default for PythonDebuggerSettings {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunSettings {
|
||||
/// Target for configurations without an explicit target. Empty = local.
|
||||
pub default_target: String,
|
||||
pub confirm_rerun: bool,
|
||||
/// Ask before closing a Run tab whose process is still running.
|
||||
pub confirm_terminate: bool,
|
||||
@@ -284,13 +282,7 @@ pub struct RunSettings {
|
||||
|
||||
impl Default for RunSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_target: String::new(),
|
||||
confirm_rerun: true,
|
||||
confirm_terminate: true,
|
||||
show_on_start: true,
|
||||
termination_timeout_ms: 3000,
|
||||
}
|
||||
Self { confirm_rerun: true, confirm_terminate: true, show_on_start: true, termination_timeout_ms: 3000 }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,4 +25,4 @@ libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber.workspace = true
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros", "test-util"] }
|
||||
tokio = { workspace = true, features = ["net", "rt-multi-thread", "macros"] }
|
||||
@@ -101,7 +101,6 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
|
||||
"lldb-dap" => {
|
||||
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
|
||||
}
|
||||
"rdbg" => cfg("rdbg", &["-O", "--port", PORT_PLACEHOLDER, "-c"], "tcp", "gem install debug"),
|
||||
// A jdtls bundle: the session is started through jdtls and reached via `debugServer`.
|
||||
"java-debug" => cfg(
|
||||
"com.microsoft.java.debug.plugin.jar",
|
||||
@@ -374,8 +373,8 @@ pub struct AdapterIo {
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// These debuggees inherit adapter stdio; no DAP event carries their output.
|
||||
const SHARES_STDIO: &[&str] = &["delve", "rdbg"];
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
@@ -477,9 +476,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
cmd.env("PATH", path);
|
||||
}
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
if spec.adapter == "rdbg" {
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
}
|
||||
cmd.args(&args)
|
||||
.envs(spec.env.iter().map(|(k, v)| (k, v)))
|
||||
.current_dir(&spec.cwd)
|
||||
@@ -489,7 +485,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if SHARES_STDIO.contains(&spec.adapter.as_str()) {
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
@@ -519,11 +515,6 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
if spec.adapter == "rdbg"
|
||||
&& let Some(pid) = child.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
let tail = stderr.lock().join("\n");
|
||||
return Err(if tail.is_empty() {
|
||||
|
||||
+11
-84
@@ -282,6 +282,7 @@ impl SessionOwner {
|
||||
}
|
||||
|
||||
const INIT_TIMEOUT: Duration = Duration::from_secs(20);
|
||||
const LAUNCH_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Redefining many classes in a large JVM can take a while.
|
||||
@@ -304,63 +305,6 @@ pub struct DebugManager {
|
||||
stopping: Mutex<HashMap<DebugSessionId, Arc<Session>>>,
|
||||
}
|
||||
|
||||
/// rdbg (Ruby's debug gem) starts the debuggee itself and ignores `launch`'s program,
|
||||
/// arguments and environment: they go on its command line after `--` and into its process.
|
||||
/// In a Bundler project it runs from the bundle, which must have `debug`: the debuggee then
|
||||
/// loads that version, and the gems rdbg needs (irb is not a default gem from Ruby 3.5).
|
||||
fn rdbg_command_line(spec: &mut AdapterSpec, config: &LaunchConfig, cwd: &Path) -> IdeResult<()> {
|
||||
if config.request != "launch" {
|
||||
return Ok(());
|
||||
}
|
||||
if spec.command == "rdbg"
|
||||
&& let Some(gemfile) = bundler_gemfile(cwd, &config.env)
|
||||
{
|
||||
if !bundles_debug(&gemfile) {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the bundle of {} has no `debug` gem. Add `gem \"debug\"` to the Gemfile and run `bundle install`",
|
||||
gemfile.display()
|
||||
)));
|
||||
}
|
||||
spec.args.splice(0..0, ["exec".to_owned(), "rdbg".to_owned()]);
|
||||
spec.command = "bundle".into();
|
||||
}
|
||||
spec.env.extend(config.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
spec.args.push("--".into());
|
||||
// With only a command, Ruby's exec treats it as shell text. Ruby entry points
|
||||
// without arguments go through -S so spaces and metacharacters stay literal.
|
||||
if config.program.is_some() && config.args.is_empty() {
|
||||
spec.args.extend(["ruby", "-S", "--"].map(str::to_owned));
|
||||
}
|
||||
spec.args.extend(config.program.iter().cloned());
|
||||
spec.args.extend(config.args.iter().cloned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The Gemfile Bundler uses in `dir`: `BUNDLE_GEMFILE`, else the closest `Gemfile` / `gems.rb` from `dir` up.
|
||||
fn bundler_gemfile(dir: &Path, env: &BTreeMap<String, String>) -> Option<PathBuf> {
|
||||
let gemfile = env
|
||||
.get("BUNDLE_GEMFILE")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("BUNDLE_GEMFILE").map(|v| v.to_string_lossy().into_owned()));
|
||||
match gemfile.filter(|g| !g.is_empty()) {
|
||||
Some(gemfile) => Some(dir.join(gemfile)),
|
||||
None => dir.ancestors().flat_map(|d| [d.join("gems.rb"), d.join("Gemfile")]).find(|g| g.is_file()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the lockfile of `gemfile` lists the `debug` gem.
|
||||
fn bundles_debug(gemfile: &Path) -> bool {
|
||||
let lockfile = if gemfile.file_name().is_some_and(|n| n == "gems.rb") {
|
||||
gemfile.with_file_name("gems.locked")
|
||||
} else {
|
||||
let mut name = gemfile.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
};
|
||||
// The bundle's gems are the lines indented by four spaces: ` debug (1.9.2)`.
|
||||
std::fs::read_to_string(lockfile).is_ok_and(|text| text.lines().any(|l| l.starts_with(" debug (")))
|
||||
}
|
||||
|
||||
/// Whether the debugged project depends on the Flutter SDK, so it must go
|
||||
/// through `flutter debug_adapter` instead of the plain Dart one.
|
||||
///
|
||||
@@ -707,9 +651,6 @@ impl DebugManager {
|
||||
{
|
||||
spec.env.push(("JAVA_HOME".into(), home));
|
||||
}
|
||||
if config.adapter == "rdbg" {
|
||||
rdbg_command_line(&mut spec, &config, &cwd)?;
|
||||
}
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
@@ -853,23 +794,14 @@ impl DebugManager {
|
||||
let stderr = stderr.clone();
|
||||
Connection::start_with_output(io.reader, io.writer, Box::new(move |line| adapter::push_line(&stderr, line)))
|
||||
};
|
||||
let rdbg_pid = (adapter_name == "rdbg" && request == "launch")
|
||||
.then(|| io.child.as_ref().and_then(tokio::process::Child::id))
|
||||
.flatten();
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
// 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);
|
||||
}
|
||||
if let Some(mut output) = io.program_output {
|
||||
session.output_drained.send_replace(false);
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
session.output_drained.send_replace(true);
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
@@ -882,12 +814,7 @@ impl DebugManager {
|
||||
});
|
||||
tokio::spawn(session::event_loop(self.me.clone(), session.clone(), incoming));
|
||||
|
||||
let configured = tokio::select! {
|
||||
biased;
|
||||
_ = session.startup_cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
result = self.configure(&session, arguments) => result,
|
||||
};
|
||||
match configured {
|
||||
match self.configure(&session, arguments).await {
|
||||
Ok(()) => Ok(id),
|
||||
Err(e) => {
|
||||
let e = if session.conn.is_closed() {
|
||||
@@ -975,9 +902,7 @@ impl DebugManager {
|
||||
arguments["hostName"] = json!("127.0.0.1");
|
||||
arguments["port"] = json!(port);
|
||||
}
|
||||
// Launch/attach may wait for a slow runtime or a remote target. Let the adapter report
|
||||
// failure; Stop cancels configuration and a closed connection fails the pending reply.
|
||||
// Many adapters answer only after `configurationDone`, so don't wait for the reply yet.
|
||||
// Many adapters answer `launch` only after `configurationDone`, so don't wait for it yet.
|
||||
let launch = s.conn.send_request(request, arguments)?;
|
||||
let mut launch = Box::pin(async move { launch.await.unwrap_or(Err(DapError::Closed)) });
|
||||
let mut initialized = s.initialized.subscribe();
|
||||
@@ -989,6 +914,7 @@ impl DebugManager {
|
||||
launch_result = Some(());
|
||||
false
|
||||
}
|
||||
_ = tokio::time::sleep(LAUNCH_TIMEOUT) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
};
|
||||
if !got_initialized {
|
||||
// Launched before `initialized`; give the adapter a moment to send it.
|
||||
@@ -1046,7 +972,12 @@ impl DebugManager {
|
||||
}
|
||||
s.configured.store(true, Ordering::Release);
|
||||
if launch_result.is_none() {
|
||||
launch.await?;
|
||||
match tokio::time::timeout(LAUNCH_TIMEOUT, launch).await {
|
||||
Ok(r) => {
|
||||
r?;
|
||||
}
|
||||
Err(_) => return Err(DapError::Timeout(s.request.clone()).into()),
|
||||
}
|
||||
}
|
||||
if let Some(relay) = s.kotlin_relay.lock().as_mut() {
|
||||
relay.release();
|
||||
@@ -1072,9 +1003,7 @@ impl DebugManager {
|
||||
// the console (`terminated`), as IDEA does with the process it owns.
|
||||
let pid = s.debuggee_pid.load(Ordering::Acquire);
|
||||
let own_terminate = terminate && s.request == "launch" && pid > 0;
|
||||
// rdbg's terminate exits the Ruby process without terminating its children.
|
||||
let graceful =
|
||||
terminate && s.request == "launch" && s.adapter != "rdbg" && s.supports("supportsTerminateRequest");
|
||||
let graceful = terminate && s.request == "launch" && s.supports("supportsTerminateRequest");
|
||||
if !s.conn.is_closed() {
|
||||
if graceful {
|
||||
let _ = s.conn.request_timeout("terminate", json!({ "restart": false }), SHUTDOWN_TIMEOUT).await;
|
||||
@@ -1730,11 +1659,9 @@ impl DebugManager {
|
||||
}
|
||||
|
||||
async fn stop_tree(&self, session: Arc<Session>) -> IdeResult<()> {
|
||||
session.startup_cancel.cancel();
|
||||
let children: Vec<Arc<Session>> =
|
||||
self.live_sessions().into_iter().filter(|c| c.parent == Some(session.id)).collect();
|
||||
for child in children {
|
||||
child.startup_cancel.cancel();
|
||||
self.shutdown(&child, true).await;
|
||||
}
|
||||
self.shutdown(&session, true).await;
|
||||
|
||||
@@ -48,8 +48,6 @@ pub(crate) struct Session {
|
||||
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
|
||||
pub configured: AtomicBool,
|
||||
pub initialized: watch::Sender<bool>,
|
||||
pub startup_cancel: ide_core::CancellationToken,
|
||||
pub output_drained: watch::Sender<bool>,
|
||||
pub terminated: AtomicBool,
|
||||
/// Set by a second Stop: the one waiting for the program kills it instead.
|
||||
pub stop_force: AtomicBool,
|
||||
@@ -105,8 +103,6 @@ impl Session {
|
||||
program: Mutex::new(None),
|
||||
configured: AtomicBool::new(false),
|
||||
initialized: watch::channel(false).0,
|
||||
startup_cancel: ide_core::CancellationToken::new(),
|
||||
output_drained: watch::channel(true).0,
|
||||
terminated: AtomicBool::new(false),
|
||||
stop_force: AtomicBool::new(false),
|
||||
debuggee_pid: AtomicI64::new(0),
|
||||
@@ -279,11 +275,6 @@ impl Session {
|
||||
pub fn kill_processes(&self) {
|
||||
self.kotlin_relay.lock().take();
|
||||
if let Some(mut c) = self.adapter_process.lock().take() {
|
||||
if self.adapter == "rdbg"
|
||||
&& let Some(pid) = c.id()
|
||||
{
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
let _ = c.start_kill();
|
||||
}
|
||||
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
|
||||
@@ -457,11 +448,6 @@ pub(crate) async fn event_loop(
|
||||
Incoming::Closed => break,
|
||||
}
|
||||
}
|
||||
// rdbg closes DAP before Ruby's remaining at_exit handlers print the test summary.
|
||||
if session.adapter == "rdbg" && session.request == "launch" {
|
||||
let mut drained = session.output_drained.subscribe();
|
||||
let _ = tokio::time::timeout(DEBUGGEE_EXIT_WAIT, drained.wait_for(|done| *done)).await;
|
||||
}
|
||||
if let Some(mgr) = manager.upgrade() {
|
||||
mgr.session_closed(&session);
|
||||
}
|
||||
@@ -602,14 +588,15 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
|
||||
|
||||
/// A `runInTerminal` program and the pseudo-terminal it runs in.
|
||||
pub(crate) struct ProgramTerminal {
|
||||
writer: ide_core::input::InputWriter,
|
||||
writer: Box<dyn std::io::Write + Send>,
|
||||
child: Box<dyn portable_pty::Child + Send + Sync>,
|
||||
_master: Box<dyn portable_pty::MasterPty + Send>,
|
||||
}
|
||||
|
||||
impl ProgramTerminal {
|
||||
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
|
||||
self.writer.write(text.as_bytes())
|
||||
self.writer.write_all(text.as_bytes())?;
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -629,16 +616,6 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
|
||||
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_writable = ide_core::input::nonblocking(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
#[cfg(unix)]
|
||||
let wait_readable = ide_core::input::readable(
|
||||
pair.master.as_raw_fd().ok_or_else(|| format!("terminal of {program}: PTY has no file descriptor"))?,
|
||||
)
|
||||
.map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
|
||||
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
|
||||
@@ -664,31 +641,12 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
};
|
||||
}
|
||||
}
|
||||
let mut child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
|
||||
drop(pair.slave);
|
||||
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
|
||||
let writer = ide_core::input::InputWriter::new(
|
||||
format!("openidea-debug-input-{}", s.id.0),
|
||||
writer,
|
||||
#[cfg(unix)]
|
||||
wait_writable,
|
||||
|e| {
|
||||
tracing::warn!("debug input: {e}");
|
||||
},
|
||||
)
|
||||
.inspect_err(|_| {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
})
|
||||
.map_err(|e| format!("terminal input worker: {e}"))?;
|
||||
let pid = child.process_id();
|
||||
forward_terminal_output(
|
||||
s,
|
||||
reader,
|
||||
#[cfg(unix)]
|
||||
wait_readable,
|
||||
);
|
||||
forward_terminal_output(s, reader);
|
||||
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
|
||||
let _ = previous.child.kill();
|
||||
}
|
||||
@@ -697,11 +655,7 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
|
||||
}
|
||||
|
||||
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
|
||||
fn forward_terminal_output(
|
||||
s: &Session,
|
||||
mut reader: Box<dyn std::io::Read + Send>,
|
||||
#[cfg(unix)] mut wait_readable: impl FnMut() -> std::io::Result<()> + Send + 'static,
|
||||
) {
|
||||
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
|
||||
let session = s.id;
|
||||
let sink = s.owner.sink.clone();
|
||||
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
|
||||
@@ -709,27 +663,8 @@ fn forward_terminal_output(
|
||||
let mut pending: Vec<u8> = Vec::new();
|
||||
loop {
|
||||
match reader.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => pending.extend_from_slice(&buf[..n]),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
#[cfg(unix)]
|
||||
if let Err(e) = wait_readable()
|
||||
&& e.kind() != std::io::ErrorKind::Interrupted
|
||||
{
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::thread::sleep(std::time::Duration::from_millis(4));
|
||||
continue;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
Err(e) if e.raw_os_error() == Some(libc::EIO) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(%session, "debug terminal read: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
|
||||
let mut valid = match std::str::from_utf8(&pending) {
|
||||
|
||||
+1
-268
@@ -33,12 +33,6 @@ struct Script {
|
||||
supports_terminate: bool,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
initialized_delay: Duration,
|
||||
launch_delay: Duration,
|
||||
/// Leave startup pending before `initialized` (false) or after configuration (true).
|
||||
pending_startup: Option<bool>,
|
||||
startup_error: Option<&'static str>,
|
||||
close_during_startup: bool,
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
@@ -53,11 +47,6 @@ impl Default for Script {
|
||||
output_after_terminate: false,
|
||||
supports_terminate: true,
|
||||
hold_set_breakpoints: 0,
|
||||
initialized_delay: Duration::ZERO,
|
||||
launch_delay: Duration::ZERO,
|
||||
pending_startup: None,
|
||||
startup_error: None,
|
||||
close_during_startup: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,15 +120,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
})
|
||||
),
|
||||
"launch" | "attach" => {
|
||||
if script.close_during_startup {
|
||||
return;
|
||||
}
|
||||
if let Some(message) = script.startup_error {
|
||||
send!(
|
||||
json!({"type": "response", "request_seq": msg["seq"], "command": msg["command"], "success": false, "message": message})
|
||||
);
|
||||
continue;
|
||||
}
|
||||
pending_launch = msg["seq"].as_i64();
|
||||
if let Some(args) = &script.run_in_terminal {
|
||||
send!(
|
||||
@@ -151,10 +131,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
json!({"type": "request", "command": "startDebugging", "arguments": {"request": "launch", "configuration": {"name": "child", "isChild": true}}})
|
||||
);
|
||||
}
|
||||
if script.pending_startup != Some(false) {
|
||||
tokio::time::sleep(script.initialized_delay).await;
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
"setBreakpoints" => {
|
||||
held.push(msg);
|
||||
@@ -205,10 +182,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
if !script.ignore_configuration_done {
|
||||
ok!(msg, Value::Null);
|
||||
}
|
||||
if script.pending_startup == Some(true) {
|
||||
continue;
|
||||
}
|
||||
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}));
|
||||
}
|
||||
@@ -378,66 +351,6 @@ fn bp(line: u32) -> Breakpoint {
|
||||
Breakpoint { line, condition: None, hit_condition: None, log_message: None, enabled: true }
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn slow_launch_and_attach_follow_adapter_startup() {
|
||||
for request in ["launch", "attach"] {
|
||||
for before_initialized in [true, false] {
|
||||
let delay = Duration::from_secs(125);
|
||||
let h = harness(Script {
|
||||
initialized_delay: if before_initialized { delay } else { Duration::ZERO },
|
||||
launch_delay: if before_initialized { Duration::ZERO } else { delay },
|
||||
..Script::default()
|
||||
});
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let id = h.start(config).await.expect("a slow adapter can finish starting");
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
assert!(!h.commands().iter().any(|c| c == "terminate" || c == "disconnect"));
|
||||
h.manager.stop(id).await.unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn stop_cancels_pending_launch_and_attach() {
|
||||
for request in ["launch", "attach"] {
|
||||
for configured in [false, true] {
|
||||
let h = harness(Script { pending_startup: Some(configured), ..Script::default() });
|
||||
let config = LaunchConfig { request: request.into(), ..config() };
|
||||
let start = h.start(config);
|
||||
tokio::pin!(start);
|
||||
let stop = async {
|
||||
loop {
|
||||
let command = if configured { "configurationDone" } else { request };
|
||||
if h.commands().iter().any(|c| c == command) {
|
||||
break;
|
||||
}
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
let id = h.manager.sessions(None)[0].id;
|
||||
h.manager.stop(id).await.unwrap();
|
||||
};
|
||||
let (result, ()) = tokio::join!(&mut start, stop);
|
||||
assert_eq!(result.unwrap_err().kind, ide_core::error::ErrorKind::Cancelled);
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(start_paused = true)]
|
||||
async fn startup_still_reports_adapter_failure_and_connection_closure() {
|
||||
for request in ["launch", "attach"] {
|
||||
let h = harness(Script { startup_error: Some("interpreter could not start"), ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert_eq!(error.message, format!("{request} failed: interpreter could not start"));
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
|
||||
let h = harness(Script { close_during_startup: true, ..Script::default() });
|
||||
let error = h.start(LaunchConfig { request: request.into(), ..config() }).await.unwrap_err();
|
||||
assert!(error.message.contains("debug adapter `debugpy` exited during startup"), "{error}");
|
||||
assert!(h.manager.sessions(None).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_protocol_flow() {
|
||||
let h = harness(Script::default());
|
||||
@@ -641,22 +554,6 @@ async fn run_in_terminal_program_reads_typed_input() {
|
||||
assert!(h.manager.send_input(session, "x\n").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn blocked_program_input_does_not_hold_up_shutdown() {
|
||||
let h = harness(Script {
|
||||
run_in_terminal: Some(vec!["sh".into(), "-c".into(), "stty raw -echo; printf READY; exec sleep 60".into()]),
|
||||
..Script::default()
|
||||
});
|
||||
let session = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("READY"))).await;
|
||||
let started = std::time::Instant::now();
|
||||
h.manager.send_input(session, &"x".repeat(512 * 1024)).unwrap();
|
||||
assert!(started.elapsed() < std::time::Duration::from_millis(250));
|
||||
h.manager.stop(session).await.unwrap();
|
||||
assert!(h.manager.send_input(session, "later").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn launch_arguments_merge_and_defaults() {
|
||||
let mut c = config();
|
||||
@@ -1494,109 +1391,6 @@ fn flutter_projects_are_found_from_the_program_or_the_working_directory() {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_the_debuggee_after_its_options_with_the_configuration_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("ruby".into()),
|
||||
args: ["-Ilib", "app with spaces.rb", "$(touch sentinel); literal"].map(String::from).to_vec(),
|
||||
env: BTreeMap::from([("RAILS_ENV".into(), "test".into())]),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(
|
||||
spec.args,
|
||||
["-O", "--port", "${port}", "-c", "--", "ruby", "-Ilib", "app with spaces.rb", "$(touch sentinel); literal"]
|
||||
);
|
||||
assert!(spec.env.contains(&("RAILS_ENV".into(), "test".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_runs_from_a_bundle_that_has_debug_and_refuses_one_without() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let app = dir.path().join("app");
|
||||
std::fs::create_dir_all(app.join("lib")).unwrap();
|
||||
std::fs::write(app.join("Gemfile"), "source \"https://rubygems.org\"\n").unwrap();
|
||||
// A dependency of another gem is not a gem of the bundle.
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n irb (1.14.0)\n debug (>= 1)\n").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig {
|
||||
adapter: "rdbg".into(),
|
||||
program: Some("bundle".into()),
|
||||
args: ["exec", "rspec"].map(String::from).to_vec(),
|
||||
extra_json: None,
|
||||
..config()
|
||||
};
|
||||
let mut spec = adapter::resolve(&rdbg, app.clone());
|
||||
let err = rdbg_command_line(&mut spec, &config, &app).unwrap_err();
|
||||
assert!(err.message.contains("gem \"debug\""), "{}", err.message);
|
||||
|
||||
std::fs::write(app.join("Gemfile.lock"), "GEM\n specs:\n debug (1.9.2)\n irb (~> 1.10)\n").unwrap();
|
||||
// From a subdirectory too: Bundler finds the Gemfile above it.
|
||||
for cwd in [app.clone(), app.join("lib")] {
|
||||
let mut spec = adapter::resolve(&rdbg, cwd.clone());
|
||||
rdbg_command_line(&mut spec, &config, &cwd).unwrap();
|
||||
assert_eq!(spec.command, "bundle");
|
||||
assert_eq!(spec.args, ["exec", "rdbg", "-O", "--port", "${port}", "-c", "--", "bundle", "exec", "rspec"]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_commands_without_arguments_are_ruby_entry_points_not_shell_text() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
for program in ["rspec", "/app with spaces/bin/rake", "$(touch sentinel); literal"] {
|
||||
let config =
|
||||
LaunchConfig { adapter: "rdbg".into(), program: Some(program.into()), args: Vec::new(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(&spec.args[4..], ["--", "ruby", "-S", "--", program]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdbg_remote_attach_does_not_check_the_local_bundle_or_append_a_program() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("Gemfile"), "").unwrap();
|
||||
let rdbg = adapter::find_config(&LanguageRegistry::from_configs(Vec::new()), "rdbg").unwrap();
|
||||
let config = LaunchConfig { adapter: "rdbg".into(), request: "attach".into(), ..config() };
|
||||
let mut spec = adapter::resolve(&rdbg, dir.path().into());
|
||||
let args = spec.args.clone();
|
||||
rdbg_command_line(&mut spec, &config, dir.path()).unwrap();
|
||||
assert_eq!(spec.command, "rdbg");
|
||||
assert_eq!(spec.args, args);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_prefers_gems_rb_when_both_gemfiles_exist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
for name in ["Gemfile", "gems.rb"] {
|
||||
std::fs::write(dir.path().join(name), "").unwrap();
|
||||
}
|
||||
assert_eq!(bundler_gemfile(dir.path(), &BTreeMap::new()), Some(dir.path().join("gems.rb")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundler_gemfile_and_lockfile_follow_bundle_gemfile_and_gems_rb() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
std::fs::create_dir_all(root.join("gems")).unwrap();
|
||||
let none = BTreeMap::new();
|
||||
assert_eq!(bundler_gemfile(root, &none), None);
|
||||
std::fs::write(root.join("gems/gems.rb"), "").unwrap();
|
||||
std::fs::write(root.join("gems/gems.locked"), "GEM\n specs:\n debug (1.9.2)\n").unwrap();
|
||||
let gems_rb = bundler_gemfile(&root.join("gems"), &none).unwrap();
|
||||
assert_eq!(gems_rb, root.join("gems/gems.rb"));
|
||||
assert!(bundles_debug(&gems_rb));
|
||||
let env = BTreeMap::from([("BUNDLE_GEMFILE".into(), "gems/gems.rb".into())]);
|
||||
assert_eq!(bundler_gemfile(root, &env), Some(gems_rb));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn stop_waits_for_the_launched_program_to_exit() {
|
||||
@@ -1814,64 +1608,3 @@ fn delve_leftovers_are_removed_without_touching_other_files() {
|
||||
}
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn rdbg_drains_test_output_after_dap_closes_before_killing_the_process() {
|
||||
struct Launcher;
|
||||
impl AdapterLauncher for Launcher {
|
||||
fn launch(&self, _spec: AdapterSpec, _reuse: Option<u16>) -> LaunchFuture {
|
||||
Box::pin(async {
|
||||
let (client, server) = tokio::io::duplex(1 << 16);
|
||||
tokio::spawn(fake_adapter(server, Arc::default(), Script::default()));
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
ide_core::process::own_group(command.as_std_mut());
|
||||
let mut child = command
|
||||
.args(["-c", "sleep 1; printf 'test summary\\n'"])
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdout = child.stdout.take().unwrap();
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
tokio::spawn(async move {
|
||||
let mut text = String::new();
|
||||
tokio::io::AsyncReadExt::read_to_string(&mut stdout, &mut text).await.unwrap();
|
||||
let _ = tx.send(("stdout", text));
|
||||
});
|
||||
let (reader, writer) = tokio::io::split(client);
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(reader),
|
||||
writer: Box::new(writer),
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: Some(rx),
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let manager = DebugManager::with_launcher(Arc::new(LanguageRegistry::from_configs(Vec::new())), Arc::new(Launcher));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let collected = events.clone();
|
||||
let owner = SessionOwner::new("rdbg-test", Arc::new(move |e| collected.lock().push(e)));
|
||||
let id = manager
|
||||
.start(
|
||||
LaunchConfig { adapter: "rdbg".into(), cwd: Some(dir.path().into()), extra_json: None, ..config() },
|
||||
owner,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
manager.session(id).unwrap().conn.close();
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while !manager.sessions(None).is_empty() {
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(events.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Debug { event: DebugEvent::Output { output, .. }, .. } if output == "test summary\n"
|
||||
)));
|
||||
}
|
||||
@@ -7,13 +7,10 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
toml_edit.workspace = true
|
||||
blake3.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
|
||||
+12
-24
@@ -7,7 +7,6 @@ pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod plugin;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
@@ -382,9 +381,7 @@ impl DbManager {
|
||||
/// Where the saved passwords of data source `id` live: app-wide for a global one.
|
||||
fn saved_for(&self, id: &str) -> &SecretStore {
|
||||
match &self.global {
|
||||
Some((_, store)) if self.sources().is_ok_and(|sources| sources.iter().any(|s| s.id == id && s.global)) => {
|
||||
store
|
||||
}
|
||||
Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store,
|
||||
_ => &self.saved_passwords,
|
||||
}
|
||||
}
|
||||
@@ -415,28 +412,19 @@ impl DbManager {
|
||||
/// Saves the data source list; connections of removed or changed sources are closed.
|
||||
/// Global ones (`global`) go to the app-wide list, the others to the project's.
|
||||
pub async fn save_sources(&self, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
if sources.iter().any(|s| s.global && s.plugin_owner.is_some()) {
|
||||
return Err(IdeError::invalid("plugin-owned data sources must stay project-scoped"));
|
||||
}
|
||||
let old = {
|
||||
let _guard = plugin::EDIT.lock();
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
let old = self.sources().unwrap_or_default();
|
||||
let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global);
|
||||
match &self.global {
|
||||
// Rewritten only when it changes: other projects' windows edit the same file.
|
||||
Some((dir, _)) => {
|
||||
if global != old.iter().filter(|s| s.global).cloned().collect::<Vec<_>>() {
|
||||
source::save_global(dir, &global)?;
|
||||
}
|
||||
None if !global.is_empty() => {
|
||||
return Err(IdeError::invalid("global data sources are not available here"));
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
old
|
||||
};
|
||||
None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")),
|
||||
None => {}
|
||||
}
|
||||
source::save(&self.root, &project)?;
|
||||
for o in old {
|
||||
match sources.iter().find(|s| s.id == o.id) {
|
||||
Some(n) if *n == o => {}
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
//! Ownership checks and surgical updates of plugin-created project sources.
|
||||
use crate::{DataSource, DbManager, source};
|
||||
use ide_core::{IdeError, IdeResult, project_dir};
|
||||
use std::path::Path;
|
||||
|
||||
// All DbManagers of the same root share this file. Never hold this across an await.
|
||||
pub(crate) static EDIT: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
|
||||
/// Host-owned connection id, unambiguous and safe for console paths.
|
||||
pub fn source_id(plugin: &str, id: &str) -> String {
|
||||
format!("plugin-{}", blake3::hash(format!("{plugin}\0{id}").as_bytes()).to_hex())
|
||||
}
|
||||
|
||||
fn owned(source: &DataSource, plugin: &str, id: &str) -> bool {
|
||||
source.id == source_id(plugin, id)
|
||||
&& source.plugin_owner.as_deref() == Some(plugin)
|
||||
&& source.plugin_source_id.as_deref() == Some(id)
|
||||
&& !source.global
|
||||
}
|
||||
|
||||
/// Edits just matching array tables, retaining unrelated entries, comments and formatting.
|
||||
fn update(root: &Path, remove: impl Fn(&DataSource) -> bool, add: Option<&DataSource>) -> IdeResult<Vec<DataSource>> {
|
||||
let path = project_dir::path(root, "data-sources.toml");
|
||||
if let Some(ancestor) = path.ancestors().find(|p| p.exists())
|
||||
&& root.exists()
|
||||
&& !dunce::canonicalize(ancestor)?.starts_with(dunce::canonicalize(root)?)
|
||||
{
|
||||
return Err(IdeError::invalid("data sources file is outside the project"));
|
||||
}
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let old = source::load(root)?;
|
||||
let doc = toml_edit::Document::parse(text.as_str()).map_err(|e| IdeError::invalid(format!("data sources: {e}")))?;
|
||||
let removed: Vec<_> = old.iter().filter(|s| remove(s)).cloned().collect();
|
||||
fn line_span(text: &str, span: std::ops::Range<usize>, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
let start = text[..span.start].rfind('\n').map_or(0, |n| n + 1);
|
||||
let mut end = text[span.end..].find('\n').map_or(text.len(), |n| span.end + n + 1);
|
||||
while end < text.len() {
|
||||
let next = text[end..].find('\n').map_or(text.len(), |n| end + n + 1);
|
||||
if !text[end..next].trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
end = next;
|
||||
}
|
||||
out.push(start..end);
|
||||
}
|
||||
fn spans(table: &toml_edit::Table, text: &str, out: &mut Vec<std::ops::Range<usize>>) {
|
||||
if !table.is_implicit()
|
||||
&& let Some(span) = table.span()
|
||||
{
|
||||
line_span(text, span, out);
|
||||
}
|
||||
for (_, item) in table.iter() {
|
||||
if let Some(span) = item.as_value().and_then(toml_edit::Value::span) {
|
||||
line_span(text, span, out);
|
||||
}
|
||||
if let Some(table) = item.as_table() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
if let Some(tables) = item.as_array_of_tables() {
|
||||
for table in tables.iter() {
|
||||
spans(table, text, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if doc.get("data-source").is_some_and(|i| i.as_array_of_tables().is_none()) {
|
||||
return Err(IdeError::invalid("plugin sources require [[data-source]] tables"));
|
||||
}
|
||||
let mut ranges = vec![];
|
||||
if let Some(tables) = doc.get("data-source").and_then(toml_edit::Item::as_array_of_tables) {
|
||||
for (table, source) in tables.iter().zip(&old) {
|
||||
if remove(source) {
|
||||
spans(table, &text, &mut ranges);
|
||||
}
|
||||
}
|
||||
}
|
||||
ranges.sort_by_key(|r| r.start);
|
||||
let mut merged: Vec<std::ops::Range<usize>> = vec![];
|
||||
for range in ranges {
|
||||
if let Some(last) = merged.last_mut()
|
||||
&& range.start <= last.end
|
||||
{
|
||||
last.end = last.end.max(range.end);
|
||||
continue;
|
||||
}
|
||||
merged.push(range);
|
||||
}
|
||||
let mut output = text.clone();
|
||||
for range in merged.into_iter().rev() {
|
||||
output.replace_range(range, "");
|
||||
}
|
||||
if let Some(add) = add {
|
||||
let mut value = toml::Value::try_from(add).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
value.as_table_mut().unwrap().remove("global");
|
||||
let value =
|
||||
toml::Value::Table(toml::Table::from_iter([("data-source".into(), toml::Value::Array(vec![value]))]));
|
||||
if !output.is_empty() && !output.ends_with('\n') {
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(&toml::to_string_pretty(&value).map_err(|e| IdeError::internal(e.to_string()))?);
|
||||
}
|
||||
if !removed.is_empty() || add.is_some() {
|
||||
let dir = path.parent().unwrap();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let tmp = dir.join(format!(".data-sources-{}.tmp", uuid::Uuid::new_v4()));
|
||||
std::fs::write(&tmp, output)?;
|
||||
if let Err(e) = std::fs::rename(&tmp, &path) {
|
||||
let _ = std::fs::remove_file(&tmp);
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
/// Removes only this plugin's sources, including when their project is closed at uninstall.
|
||||
pub fn remove_all(root: &Path, plugin: &str) -> IdeResult<Vec<DataSource>> {
|
||||
let _guard = EDIT.lock();
|
||||
update(root, |s| s.plugin_owner.as_deref() == Some(plugin), None)
|
||||
}
|
||||
|
||||
impl DbManager {
|
||||
/// Lists private source ids without exposing any connection settings.
|
||||
pub fn plugin_sources(&self, plugin: &str) -> IdeResult<Vec<String>> {
|
||||
Ok(source::load(&self.root)?
|
||||
.into_iter()
|
||||
.filter(|s| s.plugin_owner.as_deref() == Some(plugin))
|
||||
.filter_map(|s| s.plugin_source_id)
|
||||
.collect())
|
||||
}
|
||||
/// Checks the stored owner, even if a user-created source uses the host's id format.
|
||||
pub fn plugin_source(&self, plugin: &str, id: &str) -> IdeResult<DataSource> {
|
||||
source::load(&self.root)?
|
||||
.into_iter()
|
||||
.find(|s| owned(s, plugin, id))
|
||||
.ok_or_else(|| IdeError::not_found("no owned data source with this id"))
|
||||
}
|
||||
/// Replaces only an owned source and disconnects its old pools.
|
||||
pub async fn upsert_plugin_source(&self, plugin: &str, id: &str, mut source: DataSource) -> IdeResult<()> {
|
||||
source.id = source_id(plugin, id);
|
||||
source.plugin_owner = Some(plugin.into());
|
||||
source.plugin_source_id = Some(id.into());
|
||||
source.global = false;
|
||||
{
|
||||
let _guard = EDIT.lock();
|
||||
let all = self.sources()?;
|
||||
if all.iter().any(|s| s.id == source.id && !owned(s, plugin, id)) {
|
||||
return Err(IdeError::not_found("no owned data source with this id"));
|
||||
}
|
||||
if !all.iter().any(|s| owned(s, plugin, id)) && self.plugin_sources(plugin)?.len() >= 64 {
|
||||
return Err(IdeError::invalid("at most 64 sources per plugin and project"));
|
||||
}
|
||||
update(&self.root, |s| owned(s, plugin, id), Some(&source))?;
|
||||
}
|
||||
self.disconnect_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Deletes an owned source and all its saved/session credentials.
|
||||
pub async fn remove_plugin_source(&self, plugin: &str, id: &str) -> IdeResult<()> {
|
||||
let source = {
|
||||
let _guard = EDIT.lock();
|
||||
let source = self.plugin_source(plugin, id)?;
|
||||
update(&self.root, |s| owned(s, plugin, id), None)?;
|
||||
source
|
||||
};
|
||||
self.forget_plugin_source(&source.id).await;
|
||||
Ok(())
|
||||
}
|
||||
/// Completes cleanup after removal from the file.
|
||||
pub async fn forget_plugin_source(&self, id: &str) {
|
||||
for key in [id.to_owned(), crate::secrets::ssh_key(id)] {
|
||||
self.session_passwords.lock().remove(&key);
|
||||
self.saved_passwords.delete(&key);
|
||||
}
|
||||
self.disconnect_source(id).await;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{DbDriver, secrets::SecretStore};
|
||||
#[tokio::test]
|
||||
async fn ownership_preserves_unrelated_bytes_and_cleans_credentials() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let original = "# user comment\n[[data-source]] # keep\nid = 'user'\nname='Mine'\ndriver='postgres'\n\n[data-source.options]\ntimeZone = 'UTC' # retain\n";
|
||||
let file = root.join(".openidea/data-sources.toml");
|
||||
std::fs::write(&file, original).unwrap();
|
||||
let secrets = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
secrets.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let settings =
|
||||
DataSource { name: "Plugin".into(), driver: DbDriver::Postgres, save_password: true, ..Default::default() };
|
||||
db.upsert_plugin_source("a", "local", settings.clone()).await.unwrap();
|
||||
assert!(std::fs::read_to_string(&file).unwrap().starts_with(original));
|
||||
db.upsert_plugin_source("b", "local", settings.clone()).await.unwrap();
|
||||
assert_eq!(db.plugin_sources("a").unwrap(), ["local"]);
|
||||
assert!(db.plugin_source("a", "user").is_err());
|
||||
assert!(db.remove_plugin_source("a", "user").await.is_err());
|
||||
let source = db.plugin_source("a", "local").unwrap();
|
||||
db.set_password(&source.id, Some("top-secret".into()), true).await.unwrap();
|
||||
let store = SecretStore::new(secrets, &root);
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.upsert_plugin_source("a", "local", DataSource { name: "Updated".into(), ..settings }).await.unwrap();
|
||||
assert_eq!(store.get(&source.id).as_deref(), Some("top-secret"));
|
||||
db.remove_plugin_source("a", "local").await.unwrap();
|
||||
assert!(store.get(&source.id).is_none());
|
||||
assert_eq!(db.plugin_sources("b").unwrap(), ["local"]);
|
||||
remove_all(&root, "b").unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), original);
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn project_owned_credentials_do_not_replace_a_shadowed_global_sources_password() {
|
||||
ide_ssh::secrets::testing::use_memory_store();
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let global = tmp.path().join("global");
|
||||
let passwords = tmp.path().join("passwords.json");
|
||||
let db = DbManager::new(
|
||||
root.clone(),
|
||||
passwords.clone(),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
)
|
||||
.with_global_sources(global.clone());
|
||||
db.upsert_plugin_source(
|
||||
"plugin",
|
||||
"local",
|
||||
DataSource { name: "Owned".into(), save_password: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let owned = db.plugin_source("plugin", "local").unwrap();
|
||||
source::save_global(
|
||||
&global,
|
||||
&[DataSource { id: owned.id.clone(), name: "Global user source".into(), ..Default::default() }],
|
||||
)
|
||||
.unwrap();
|
||||
let global_store = SecretStore::new(passwords.clone(), &global);
|
||||
global_store.set(&owned.id, "user-secret").unwrap();
|
||||
db.set_password(&owned.id, Some("plugin-secret".into()), true).await.unwrap();
|
||||
assert_eq!(SecretStore::new(passwords, &root).get(&owned.id).as_deref(), Some("plugin-secret"));
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
db.remove_plugin_source("plugin", "local").await.unwrap();
|
||||
assert_eq!(global_store.get(&owned.id).as_deref(), Some("user-secret"));
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn a_user_source_cannot_be_claimed_by_predicting_its_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let db = DbManager::new(
|
||||
tmp.path().join("project"),
|
||||
tmp.path().join("passwords.json"),
|
||||
tmp.path().join("history.json"),
|
||||
std::sync::Arc::new(ide_ssh::SshManager::new(tmp.path().join("ssh.json"))),
|
||||
);
|
||||
let source = DataSource { id: source_id("plugin", "x"), name: "User".into(), ..Default::default() };
|
||||
db.save_sources(vec![source.clone()]).await.unwrap();
|
||||
assert!(db.upsert_plugin_source("plugin", "x", source.clone()).await.is_err());
|
||||
assert_eq!(db.sources().unwrap(), [source]);
|
||||
}
|
||||
}
|
||||
@@ -190,12 +190,6 @@ pub struct DataSource {
|
||||
/// Stored app-wide and listed in every project. Implied by the file it is read from,
|
||||
/// so not written to it.
|
||||
pub global: bool,
|
||||
/// Plugin id owning this source; absent for user-managed sources.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_owner: Option<String>,
|
||||
/// Id private to the owner, never used as a connection target.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub plugin_source_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for DataSource {
|
||||
@@ -225,8 +219,6 @@ impl Default for DataSource {
|
||||
introspection: DbIntrospection::default(),
|
||||
properties: Vec::new(),
|
||||
global: false,
|
||||
plugin_owner: None,
|
||||
plugin_source_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,6 @@ pub enum Capability {
|
||||
EditorWrite,
|
||||
/// Create and replace files inside the project root.
|
||||
ProjectWrite,
|
||||
/// Run arbitrary IDE actions and other plugins' commands.
|
||||
IdeCommands,
|
||||
/// Manage only its own project database sources and write their credentials.
|
||||
DatabaseSources,
|
||||
VcsRead,
|
||||
VcsWrite,
|
||||
/// The plugin's own key-value store and data directory.
|
||||
@@ -36,12 +32,8 @@ pub enum Capability {
|
||||
InputEvents,
|
||||
/// The system clipboard, from the plugin's web views.
|
||||
Clipboard,
|
||||
/// HTTPS (and loopback HTTP) to one host (`*.example.com` matches subdomains).
|
||||
/// HTTP(S) to one host (`*.example.com` matches subdomains).
|
||||
Net(String),
|
||||
/// Unencrypted HTTP to one host.
|
||||
NetHttp(String),
|
||||
/// A local socket or named pipe on the project machine.
|
||||
Ipc(String),
|
||||
/// Run one external program. Full code execution: never fine-grained.
|
||||
Process(String),
|
||||
/// Read files outside the project under one path.
|
||||
@@ -68,14 +60,12 @@ impl Capability {
|
||||
Self::ProjectRead | Self::EditorRead | Self::Storage | Self::Events | Self::VcsRead => Risk::Low,
|
||||
Self::EditorWrite
|
||||
| Self::ProjectWrite
|
||||
| Self::DatabaseSources
|
||||
| Self::VcsWrite
|
||||
| Self::Net(_)
|
||||
| Self::NetHttp(_)
|
||||
| Self::Secrets
|
||||
| Self::InputEvents
|
||||
| Self::Clipboard => Risk::Medium,
|
||||
Self::IdeCommands | Self::Ipc(_) | Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
Self::Process(_) | Self::FsRead(_) | Self::FsWrite(_) => Risk::High,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,8 +82,6 @@ impl Capability {
|
||||
Self::EditorRead => "Read the focused editor's file and selection".into(),
|
||||
Self::EditorWrite => "Change text in the editor".into(),
|
||||
Self::ProjectWrite => "Create and change files in the open project".into(),
|
||||
Self::DatabaseSources => "Manage its own database sources and store their passwords in trusted projects".into(),
|
||||
Self::IdeCommands => "Run any IDE action or other plugins' commands as if you chose them, including running programs, committing and pushing".into(),
|
||||
Self::VcsRead => "Read version control state".into(),
|
||||
Self::VcsWrite => "Change version control state (commit, fetch, checkout)".into(),
|
||||
Self::Storage => "Store its own data".into(),
|
||||
@@ -104,12 +92,6 @@ impl Capability {
|
||||
"Copy to the clipboard from its commands, and read and write it from its web views".into()
|
||||
}
|
||||
Self::Net(host) => format!("Connect to {host} (from the machine the project is on)"),
|
||||
Self::NetHttp(host) => {
|
||||
format!("Connect to {host} over unencrypted HTTP (from the machine the project is on)")
|
||||
}
|
||||
Self::Ipc(path) => {
|
||||
format!("Connect to local socket {path} — equivalent to running arbitrary code on the project machine")
|
||||
}
|
||||
Self::Process(bin) => format!("Run {bin} — equivalent to running arbitrary code on this machine"),
|
||||
Self::FsRead(path) => format!("Read files under {path}, outside the project"),
|
||||
Self::FsWrite(path) => format!("Change files under {path}, outside the project"),
|
||||
@@ -118,14 +100,8 @@ impl Capability {
|
||||
|
||||
/// Whether `self`, granted, also covers `wanted` (`net:*.x.com` covers `net:a.x.com`).
|
||||
pub fn covers(&self, wanted: &Capability) -> bool {
|
||||
if self == wanted {
|
||||
return true;
|
||||
}
|
||||
match (self, wanted) {
|
||||
(Self::Ipc(pattern), Self::Ipc(path)) => ipc_matches(pattern, path),
|
||||
(Self::Net(pattern), Self::Net(host)) | (Self::NetHttp(pattern), Self::NetHttp(host)) => {
|
||||
host_matches(pattern, host)
|
||||
}
|
||||
(Self::Net(pattern), Self::Net(host)) => host_matches(pattern, host),
|
||||
(Self::FsRead(base), Self::FsRead(path)) | (Self::FsWrite(base), Self::FsWrite(path)) => {
|
||||
std::path::Path::new(path).starts_with(std::path::Path::new(base))
|
||||
}
|
||||
@@ -144,75 +120,6 @@ pub fn host_matches(pattern: &str, host: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates a socket path; a grant may use `*` in its last segment only.
|
||||
pub fn validate_ipc_name(name: &str, pattern: bool) -> Result<(), String> {
|
||||
let pipe = name.starts_with(r"\\.\pipe\");
|
||||
let unix = name.starts_with('/')
|
||||
|| name.starts_with("~/")
|
||||
|| name.starts_with("$TMPDIR/")
|
||||
|| name.starts_with("$XDG_RUNTIME_DIR/");
|
||||
let parts: Vec<_> = name.strip_prefix(r"\\.\pipe\").unwrap_or(name).split(if pipe { '\\' } else { '/' }).collect();
|
||||
if !(pipe || unix)
|
||||
|| name.contains(['\0', '?'])
|
||||
|| name.len() > 4096
|
||||
|| parts.last().is_none_or(|p| p.is_empty())
|
||||
|| parts.iter().any(|p| matches!(*p, "." | ".."))
|
||||
|| parts[..parts.len().saturating_sub(1)].iter().any(|p| p.contains('*'))
|
||||
|| (!pattern && name.contains('*'))
|
||||
{
|
||||
return Err(
|
||||
"ipc: use an absolute socket path or \\\\.\\pipe\\name; only the last grant segment may contain '*'".into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expands the three supported host-local prefixes; no shell is involved.
|
||||
pub fn expand_ipc_name(name: &str) -> Result<String, String> {
|
||||
validate_ipc_name(name, true)?;
|
||||
for (prefix, key) in [("~/", "HOME"), ("$TMPDIR/", "TMPDIR"), ("$XDG_RUNTIME_DIR/", "XDG_RUNTIME_DIR")] {
|
||||
if let Some(rest) = name.strip_prefix(prefix) {
|
||||
let base = std::env::var(key).map_err(|_| format!("{key} is not set"))?;
|
||||
let expanded = format!("{}/{rest}", base.trim_end_matches('/'));
|
||||
validate_ipc_name(&expanded, true)?;
|
||||
return Ok(expanded);
|
||||
}
|
||||
}
|
||||
Ok(name.into())
|
||||
}
|
||||
|
||||
fn ipc_matches(pattern: &str, name: &str) -> bool {
|
||||
let (Ok(pattern), Ok(name)) = (expand_ipc_name(pattern), expand_ipc_name(name)) else { return false };
|
||||
if validate_ipc_name(&name, false).is_err() {
|
||||
return false;
|
||||
}
|
||||
let separator = if pattern.starts_with(r"\\.\pipe\") { '\\' } else { '/' };
|
||||
let (Some((parent, pattern)), Some((other, name))) = (pattern.rsplit_once(separator), name.rsplit_once(separator))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
if parent != other {
|
||||
return false;
|
||||
}
|
||||
let mut rest = name;
|
||||
let mut pieces = pattern.split('*');
|
||||
let first = pieces.next().unwrap_or_default();
|
||||
let Some(tail) = rest.strip_prefix(first) else { return false };
|
||||
rest = tail;
|
||||
let pieces: Vec<_> = pieces.collect();
|
||||
if pieces.is_empty() {
|
||||
return rest.is_empty();
|
||||
}
|
||||
for (i, piece) in pieces.iter().enumerate() {
|
||||
if i + 1 == pieces.len() {
|
||||
return rest.ends_with(piece);
|
||||
}
|
||||
let Some(at) = rest.find(piece) else { return false };
|
||||
rest = &rest[at + piece.len()..];
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
impl fmt::Display for Capability {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -220,8 +127,6 @@ impl fmt::Display for Capability {
|
||||
Self::EditorRead => f.write_str("editor.read"),
|
||||
Self::EditorWrite => f.write_str("editor.write"),
|
||||
Self::ProjectWrite => f.write_str("project.write"),
|
||||
Self::IdeCommands => f.write_str("ide.commands"),
|
||||
Self::DatabaseSources => f.write_str("database.sources"),
|
||||
Self::VcsRead => f.write_str("vcs.read"),
|
||||
Self::VcsWrite => f.write_str("vcs.write"),
|
||||
Self::Storage => f.write_str("storage"),
|
||||
@@ -230,8 +135,6 @@ impl fmt::Display for Capability {
|
||||
Self::InputEvents => f.write_str("events.input"),
|
||||
Self::Clipboard => f.write_str("clipboard"),
|
||||
Self::Net(h) => write!(f, "net:{h}"),
|
||||
Self::NetHttp(h) => write!(f, "net.http:{h}"),
|
||||
Self::Ipc(p) => write!(f, "ipc:{p}"),
|
||||
Self::Process(b) => write!(f, "process:{b}"),
|
||||
Self::FsRead(p) => write!(f, "fs.read:{p}"),
|
||||
Self::FsWrite(p) => write!(f, "fs.write:{p}"),
|
||||
@@ -250,7 +153,7 @@ impl FromStr for Capability {
|
||||
return Err(format!("capability {s:?} needs a value after ':'"));
|
||||
}
|
||||
return match kind {
|
||||
"net" | "net.http" => {
|
||||
"net" => {
|
||||
let host = arg.strip_prefix("*.").unwrap_or(arg);
|
||||
let valid = !host.is_empty()
|
||||
&& host.len() <= 253
|
||||
@@ -259,19 +162,7 @@ impl FromStr for Capability {
|
||||
&& label.len() <= 63
|
||||
&& label.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
|
||||
});
|
||||
if valid {
|
||||
Ok(if kind == "net" {
|
||||
Self::Net(arg.to_ascii_lowercase())
|
||||
} else {
|
||||
Self::NetHttp(arg.to_ascii_lowercase())
|
||||
})
|
||||
} else {
|
||||
Err(format!("invalid host in {s:?}"))
|
||||
}
|
||||
}
|
||||
"ipc" => {
|
||||
validate_ipc_name(arg, true)?;
|
||||
Ok(Self::Ipc(arg.to_owned()))
|
||||
if valid { Ok(Self::Net(arg.to_ascii_lowercase())) } else { Err(format!("invalid host in {s:?}")) }
|
||||
}
|
||||
"process" => {
|
||||
if arg.contains(['/', '\\']) && !std::path::Path::new(arg).is_absolute() {
|
||||
@@ -293,8 +184,6 @@ impl FromStr for Capability {
|
||||
"editor.read" | "editor-read" => Self::EditorRead,
|
||||
"editor.write" | "editor-write" => Self::EditorWrite,
|
||||
"project.write" => Self::ProjectWrite,
|
||||
"ide.commands" => Self::IdeCommands,
|
||||
"database.sources" => Self::DatabaseSources,
|
||||
"vcs.read" => Self::VcsRead,
|
||||
"vcs.write" => Self::VcsWrite,
|
||||
"storage" => Self::Storage,
|
||||
@@ -403,71 +292,3 @@ mod tests {
|
||||
assert!(!Capability::FsRead("/etc".into()).covers(&Capability::FsRead("/etcx".into())));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn http_grants_do_not_imply_https_and_socket_patterns_stay_in_one_directory() {
|
||||
let http: Capability = "net.http:*.example.com".parse().unwrap();
|
||||
assert_eq!(http.to_string(), "net.http:*.example.com");
|
||||
assert_eq!(http.risk(), Risk::Medium);
|
||||
assert!(http.describe().contains("unencrypted"));
|
||||
assert!(http.covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
assert!(!http.covers(&"net:a.example.com".parse().unwrap()));
|
||||
assert!(!"net:*.example.com".parse::<Capability>().unwrap().covers(&"net.http:a.example.com".parse().unwrap()));
|
||||
let socket: Capability = "ipc:/tmp/discord-ipc-*-socket".parse().unwrap();
|
||||
assert_eq!(socket.risk(), Risk::High);
|
||||
assert!(socket.covers(&Capability::Ipc("/tmp/discord-ipc-42-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/discord-ipc-dir/evil-socket".into())));
|
||||
assert!(!socket.covers(&Capability::Ipc("/tmp/../tmp/discord-ipc-42-socket".into())));
|
||||
assert_eq!("ipc:$TMPDIR/discord-ipc-*".parse::<Capability>().unwrap().to_string(), "ipc:$TMPDIR/discord-ipc-*");
|
||||
assert!(
|
||||
r"ipc:\\.\pipe\discord-*"
|
||||
.parse::<Capability>()
|
||||
.unwrap()
|
||||
.covers(&Capability::Ipc(r"\\.\pipe\discord-0".into()))
|
||||
);
|
||||
for invalid in
|
||||
["ipc:relative", "ipc:/tmp/*/socket", "ipc:/tmp/../socket", "ipc:/tmp/", "ipc:/tmp/socket?", "net.http:a b"]
|
||||
{
|
||||
assert!(invalid.parse::<Capability>().is_err(), "{invalid}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_is_explicit_and_high_risk() {
|
||||
let cap: Capability = "ide.commands".parse().unwrap();
|
||||
assert_eq!(cap, Capability::IdeCommands);
|
||||
assert_eq!(cap.to_string(), "ide.commands");
|
||||
assert_eq!(cap.risk(), Risk::High);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::VcsWrite.covers(&cap));
|
||||
assert!(!Capability::Process("git".into()).covers(&cap));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod database_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn database_sources_is_a_medium_risk_exact_grant_requiring_api4() {
|
||||
let cap: Capability = "database.sources".parse().unwrap();
|
||||
assert_eq!(cap, Capability::DatabaseSources);
|
||||
assert_eq!(cap.to_string(), "database.sources");
|
||||
assert_eq!(cap.risk(), Risk::Medium);
|
||||
assert!(!cap.auto_migrates());
|
||||
assert!(!Capability::ProjectRead.covers(&cap));
|
||||
assert!(!Capability::ProjectWrite.covers(&cap));
|
||||
for (api, valid) in [("^1.3", false), ("^1.4", true)] {
|
||||
let text = format!(
|
||||
"id='db-test'\nname='DB'\nversion='1.0.0'\nengines.openidea='{api}'\n[capabilities]\nrequired=['database.sources']"
|
||||
);
|
||||
assert_eq!(crate::Manifest::parse(&text).is_ok(), valid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,12 +59,6 @@ pub struct Grant {
|
||||
/// `process:<program>` → the program confirmed for it.
|
||||
#[serde(default)]
|
||||
pub binaries: BTreeMap<String, BinaryGrant>,
|
||||
/// Project root → process grant → the executable approved for that project.
|
||||
#[serde(default)]
|
||||
pub project_binaries: BTreeMap<PathBuf, BTreeMap<String, BinaryGrant>>,
|
||||
/// Explicit global executable choices, retained for the next re-approval.
|
||||
#[serde(default)]
|
||||
pub program_choices: BTreeMap<String, PathBuf>,
|
||||
/// Migrated from 0.1: capabilities not granted yet are asked for on first
|
||||
/// use instead of blocking the plugin from loading.
|
||||
#[serde(default)]
|
||||
@@ -254,9 +248,6 @@ impl Ledger {
|
||||
} else {
|
||||
if let Capability::Process(_) = cap {
|
||||
grant.binaries.remove(&s);
|
||||
for binaries in grant.project_binaries.values_mut() {
|
||||
binaries.remove(&s);
|
||||
}
|
||||
}
|
||||
grant.granted.remove(&s)
|
||||
}
|
||||
@@ -269,58 +260,6 @@ impl Ledger {
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's explicit global executable choice, if any.
|
||||
pub fn program_choice(&self, subject: &Subject, cap: &Capability) -> Option<&Path> {
|
||||
self.grants.get(subject)?.program_choices.get(&cap.to_string()).map(PathBuf::as_path)
|
||||
}
|
||||
/// Remembers or resets an explicit global executable choice after confirmation.
|
||||
pub fn set_program_choice(&mut self, subject: &Subject, cap: &Capability, path: Option<&Path>) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(path) = path {
|
||||
grant.program_choices.insert(cap.to_string(), path.into());
|
||||
} else {
|
||||
grant.program_choices.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates a project-specific program choice after user confirmation.
|
||||
pub fn set_project_binary(
|
||||
&mut self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: &Path,
|
||||
binary: Option<BinaryGrant>,
|
||||
) {
|
||||
if let Some(grant) = self.grants.get_mut(subject) {
|
||||
if let Some(binary) = binary {
|
||||
grant.project_binaries.entry(root.into()).or_default().insert(cap.to_string(), binary);
|
||||
} else if let Some(binaries) = grant.project_binaries.get_mut(root) {
|
||||
binaries.remove(&cap.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The project override when present, otherwise the global approved program.
|
||||
pub fn check_binary_in_project(
|
||||
&self,
|
||||
subject: &Subject,
|
||||
cap: &Capability,
|
||||
root: Option<&Path>,
|
||||
) -> Result<PathBuf, String> {
|
||||
let grant = self.grants.get(subject).ok_or_else(|| format!("{cap} was not granted"))?;
|
||||
if !grant.has(cap) {
|
||||
return Err(format!("{cap} was not granted"));
|
||||
}
|
||||
if let Some(bin) = root.and_then(|root| grant.project_binaries.get(root)).and_then(|b| b.get(&cap.to_string()))
|
||||
{
|
||||
return match file_hash(&bin.path) {
|
||||
Ok(hash) if hash == bin.hash => Ok(bin.path.clone()),
|
||||
_ => Err(format!("{} changed or is missing; confirm {cap} again", bin.path.display())),
|
||||
};
|
||||
}
|
||||
self.check_binary(subject, cap)
|
||||
}
|
||||
|
||||
/// Checks a `process:` grant at call time: the program confirmed for it,
|
||||
/// unchanged since. Errors name what to do.
|
||||
pub fn check_binary(&self, subject: &Subject, cap: &Capability) -> Result<PathBuf, String> {
|
||||
@@ -367,16 +306,6 @@ pub fn by_risk(caps: &[Capability]) -> Vec<(Risk, Vec<Capability>)> {
|
||||
groups.into_iter().map(|(r, v)| (r.0, v)).collect()
|
||||
}
|
||||
|
||||
/// Pins an explicitly selected executable, retaining a multicall shim's filename.
|
||||
pub fn approve_program(path: &Path) -> Result<BinaryGrant, String> {
|
||||
if !path.is_absolute() || !is_executable(path) {
|
||||
return Err("choose an absolute executable file".into());
|
||||
}
|
||||
let path = canonical_dir(path);
|
||||
let hash = file_hash(&path).map_err(|e| e.to_string())?;
|
||||
Ok(BinaryGrant { path, hash })
|
||||
}
|
||||
|
||||
/// Resolves a `process:` program to an absolute path once, at grant time:
|
||||
/// absolute names are taken as is, others are looked up in `path_var`.
|
||||
/// The directory is canonicalized but the file name kept as found: `cargo`,
|
||||
|
||||
@@ -24,7 +24,7 @@ mod registry;
|
||||
mod ui;
|
||||
mod when;
|
||||
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, expand_ipc_name, host_matches, validate_ipc_name};
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
|
||||
@@ -32,7 +32,7 @@ use std::path::{Component, Path, PathBuf};
|
||||
|
||||
pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
/// The plugin API (engine) version this host implements.
|
||||
pub const ENGINE_VERSION: &str = "1.4.0";
|
||||
pub const ENGINE_VERSION: &str = "1.3.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
@@ -144,20 +144,8 @@ impl ActivationEvent {
|
||||
|
||||
/// Workbench event kinds (`on-event:<kind>`, WIT `events.event-kind`).
|
||||
/// `scheduled` comes from `on-schedule` instead.
|
||||
pub const EVENT_KINDS: &[&str] = &[
|
||||
"run-exited",
|
||||
"terminal-exited",
|
||||
"content-tab-action",
|
||||
"file-closed",
|
||||
"settings-changed",
|
||||
"input-mode-changed",
|
||||
"file-opened",
|
||||
"file-saved",
|
||||
"files-changed",
|
||||
"editor-focused",
|
||||
"action-performed",
|
||||
"vcs-changed",
|
||||
];
|
||||
pub const EVENT_KINDS: &[&str] =
|
||||
&["file-opened", "file-saved", "files-changed", "editor-focused", "action-performed", "vcs-changed"];
|
||||
|
||||
impl std::fmt::Display for ActivationEvent {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
@@ -329,12 +317,6 @@ impl Manifest {
|
||||
check_relative(icon, "icon")?;
|
||||
}
|
||||
for cap in self.capabilities.required.iter().chain(&self.capabilities.optional) {
|
||||
if matches!(
|
||||
cap,
|
||||
Capability::IdeCommands | Capability::DatabaseSources | Capability::NetHttp(_) | Capability::Ipc(_)
|
||||
) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 4)?;
|
||||
}
|
||||
if matches!(cap, Capability::ProjectWrite | Capability::FsWrite(_)) {
|
||||
self.needs_api(&format!("capability `{cap}`"), 3)?;
|
||||
}
|
||||
@@ -350,10 +332,6 @@ impl Manifest {
|
||||
}
|
||||
let mut seen = BTreeSet::new();
|
||||
for event in &self.activation {
|
||||
if matches!(event, ActivationEvent::Event(kind) if matches!(kind.as_str(), "file-closed" | "settings-changed" | "input-mode-changed" | "run-exited" | "terminal-exited" | "content-tab-action"))
|
||||
{
|
||||
self.needs_api(&format!("`{event}`"), 4)?;
|
||||
}
|
||||
if matches!(event, ActivationEvent::Event(kind) if kind == "files-changed") {
|
||||
self.needs_api("`on-event:files-changed`", 3)?;
|
||||
}
|
||||
@@ -677,44 +655,3 @@ mod tests {
|
||||
assert!(!engine_compatible("0.10", true));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod api14_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn new_capabilities_and_activations_require_api_1_4() {
|
||||
for declaration in [
|
||||
"[capabilities]\nrequired = [\"net.http:example.com\"]",
|
||||
"[capabilities]\noptional = [\"ipc:$TMPDIR/test-*\"]",
|
||||
"activation = [\"on-event:file-closed\"]",
|
||||
"activation = [\"on-event:settings-changed\"]",
|
||||
"activation = [\"on-event:input-mode-changed\"]",
|
||||
"activation = [\"on-event:run-exited\"]",
|
||||
"activation = [\"on-event:terminal-exited\"]",
|
||||
"activation = [\"on-event:content-tab-action\"]",
|
||||
] {
|
||||
for engine in ["^1.0", "^1.3", "^1.4"] {
|
||||
let manifest = format!(
|
||||
"id = 'test'\nname = 'Test'\nversion = '0.1.0'\nwasm = 'plugin.wasm'\nengines.openidea = '{engine}'\n{declaration}\n"
|
||||
);
|
||||
let parsed = Manifest::parse(&manifest);
|
||||
if engine == "^1.4" {
|
||||
parsed.unwrap();
|
||||
} else {
|
||||
assert!(parsed.unwrap_err().contains("new in plugin API 1.4"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod commands_api_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn ide_commands_requires_api_1_4() {
|
||||
let manifest = "id = \"test\"\nname = \"Test\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.3\"\n[capabilities]\nrequired = [\"ide.commands\"]\n";
|
||||
assert!(Manifest::parse(manifest).unwrap_err().contains("1.4"));
|
||||
assert!(Manifest::parse(&manifest.replace("^1.3", "^1.4")).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,9 @@ path = "src/bin/server.rs"
|
||||
[dependencies]
|
||||
encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-mcp.workspace = true
|
||||
futures.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
regex-lite = "0.1"
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
blake3.workspace = true
|
||||
@@ -52,7 +49,6 @@ ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
jsonschema = { version = "0.33", default-features = false }
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
toml.workspace = true
|
||||
specta.workspace = true
|
||||
|
||||
@@ -130,38 +130,6 @@ pub async fn format(
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let settings = project.settings.effective();
|
||||
let language = project.languages.get(&snap.language);
|
||||
if options.tool.is_none()
|
||||
&& let Some(id) = settings.languages.plugin_formatters.get(snap.language.as_str()).filter(|s| !s.is_empty())
|
||||
{
|
||||
if options.on_save && !settings.editor.format_on_save {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if snap.read_only {
|
||||
return Err(IdeError::invalid("The file is read-only"));
|
||||
}
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let id = id.clone();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
plugins.language_format(
|
||||
&id,
|
||||
&d,
|
||||
range,
|
||||
ide_plugin::language_features::FormatOptions {
|
||||
tab_size: options.tab_size,
|
||||
insert_spaces: options.insert_spaces,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
|
||||
if options.tool.is_none()
|
||||
&& snap.path.extension().is_some_and(|e| e == "php")
|
||||
|
||||
@@ -8,7 +8,6 @@ pub mod environment;
|
||||
mod formatter;
|
||||
mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod mcp;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod phpstan;
|
||||
@@ -55,10 +54,6 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub(crate) mcp_executable: RwLock<Option<PathBuf>>,
|
||||
plugin_host: RwLock<std::sync::Weak<plugins::PluginHost>>,
|
||||
pub mcp_reports: mcp::Reports,
|
||||
pub mcp_requests: mcp::Requests,
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
/// Persistent user state, including credentials and recovery history.
|
||||
@@ -101,14 +96,6 @@ pub struct Host {
|
||||
const EXIT_SHUTDOWN_WAIT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
impl Host {
|
||||
/// Windows whose current project has this root.
|
||||
pub(crate) fn windows_for_root(&self, root: &std::path::Path) -> Vec<String> {
|
||||
let mut windows: Vec<_> =
|
||||
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
|
||||
windows.sort();
|
||||
windows
|
||||
}
|
||||
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig {
|
||||
config_dir,
|
||||
@@ -125,10 +112,6 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
plugin_host: RwLock::default(),
|
||||
mcp_executable: RwLock::default(),
|
||||
mcp_reports: Default::default(),
|
||||
mcp_requests: Default::default(),
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
proxy_session_password: Mutex::default(),
|
||||
@@ -178,19 +161,6 @@ impl Host {
|
||||
}
|
||||
}
|
||||
|
||||
/// Associates the plugin runtime that executes this host's project operations.
|
||||
/// Publishes only the executable of the running built-in MCP service.
|
||||
pub fn set_mcp_executable(&self, executable: Option<PathBuf>) {
|
||||
*self.mcp_executable.write() = executable;
|
||||
}
|
||||
|
||||
pub fn set_plugin_host(&self, plugins: &Arc<plugins::PluginHost>) {
|
||||
*self.plugin_host.write() = Arc::downgrade(plugins);
|
||||
}
|
||||
pub(crate) fn plugin_host(&self) -> Option<Arc<plugins::PluginHost>> {
|
||||
self.plugin_host.read().upgrade()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
@@ -249,18 +219,11 @@ impl Host {
|
||||
/// so the window can open another project at once. Returns the thread that
|
||||
/// finishes shutting down its workers.
|
||||
fn begin_close(&self, window: &str) -> Option<std::thread::JoinHandle<()>> {
|
||||
self.mcp_reports.forget_window(window);
|
||||
self.mcp_requests.forget_window(window);
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if self.windows_for_root(&project.root).is_empty()
|
||||
&& let Some(plugins) = self.plugin_host()
|
||||
{
|
||||
plugins.withdraw_project_run_targets(&project.root);
|
||||
}
|
||||
project.files_events_active.store(false, std::sync::atomic::Ordering::Release);
|
||||
project.plugin_diagnostics.close();
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
|
||||
@@ -1,448 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchToolset.kt, FileToolset.kt), Apache-2.0.
|
||||
//! Changing the project and the editor: new files, patches, formatting and imports,
|
||||
//! rename, open editors.
|
||||
//!
|
||||
//! Text changes go through the document layer: an open editor's buffer is
|
||||
//! patched in place and remains unsaved. Other files are opened transiently,
|
||||
//! saved and closed. Saves record Local History like the IDE's own edits.
|
||||
|
||||
mod diff;
|
||||
mod format;
|
||||
mod patch;
|
||||
mod rename;
|
||||
|
||||
use crate::Project;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::event::McpUiRequest;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
use patch::FileOp;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![create_new_file(), apply_patch(), format::reformat_file(), rename::rename_refactoring(), open_file_in_editor()]
|
||||
}
|
||||
|
||||
/// What tools without a result value answer (JetBrains renders `Unit` so).
|
||||
const SUCCESS: &str = "[success]";
|
||||
|
||||
// --- the document layer ----------------------------------------------------------
|
||||
|
||||
/// Whether the file is open with unsaved changes.
|
||||
fn is_dirty(project: &Project, path: &Path) -> bool {
|
||||
project.documents.by_path(path).is_some_and(|d| project.documents.is_dirty(d).unwrap_or(false))
|
||||
}
|
||||
|
||||
async fn create_or_replace(call: &Call, path: PathBuf, text: Option<String>, overwrite: bool) -> IdeResult<()> {
|
||||
let project = call.project.clone();
|
||||
let edit = call
|
||||
.host
|
||||
.blocking(move || {
|
||||
let mut files = Vec::new();
|
||||
if path.exists() {
|
||||
if !overwrite {
|
||||
return Err(IdeError::invalid("File already exists"));
|
||||
}
|
||||
if let Some(text) = text {
|
||||
let info = project.documents.open(&path)?;
|
||||
let result: IdeResult<()> = (|| {
|
||||
let snap = project.documents.text_snapshot(info.id)?;
|
||||
let doc = ide_text::Document::new(&snap.text);
|
||||
let edits = doc
|
||||
.diff(&text)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(doc.offset_to_pos(c.from), doc.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect();
|
||||
files.push(FileEdit::Edit {
|
||||
path: path.into(),
|
||||
edits,
|
||||
version: Some(
|
||||
i32::try_from(snap.version)
|
||||
.map_err(|_| IdeError::invalid("document version is out of range"))?,
|
||||
),
|
||||
});
|
||||
Ok(())
|
||||
})();
|
||||
project.documents.close(info.id);
|
||||
result?;
|
||||
}
|
||||
} else {
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: false });
|
||||
if let Some(text) = text {
|
||||
files.push(FileEdit::Edit {
|
||||
path: path.into(),
|
||||
edits: vec![TextEdit { range: Range::point(Pos::new(0, 0)), new_text: text }],
|
||||
version: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(WorkspaceEdit { files, preconditions: Vec::new() })
|
||||
})
|
||||
.await?;
|
||||
if edit.files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&call.host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&call.host, call.window(), edit).await
|
||||
}
|
||||
|
||||
/// The open document of `path`, or a transient one (`true`: close it after use).
|
||||
fn open_document(project: &Project, path: &Path) -> IdeResult<(DocId, bool)> {
|
||||
match project.documents.by_path(path) {
|
||||
Some(doc) => Ok((doc, false)),
|
||||
None => Ok((project.documents.open(path)?.id, true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_newlines(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").replace('\r', "\n")
|
||||
}
|
||||
|
||||
/// The range of a whole text.
|
||||
fn whole_range(text: &str) -> Range {
|
||||
let count = text.split('\n').count();
|
||||
let last = text.rsplit('\n').next().unwrap_or("");
|
||||
Range::new(Pos::new(0, 0), Pos::new(count.saturating_sub(1) as u32, last.encode_utf16().count() as u32))
|
||||
}
|
||||
|
||||
/// Whether two paths name the same file (language servers report canonical paths; on a
|
||||
/// case-insensitive file system `a.txt` and `A.txt` are one file).
|
||||
fn same_file(a: &Path, b: &Path) -> bool {
|
||||
if a == b || matches!((dunce::canonicalize(a), dunce::canonicalize(b)), (Ok(x), Ok(y)) if x == y) {
|
||||
return true;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
if let (Ok(x), Ok(y)) = (std::fs::metadata(a), std::fs::metadata(b)) {
|
||||
return x.dev() == y.dev() && x.ino() == y.ino();
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// The text edits of `edit` for `path`.
|
||||
fn edits_for(edit: &WorkspaceEdit, path: &Path) -> Vec<TextEdit> {
|
||||
edit.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path: p, edits, .. } if same_file(p, path) => Some(edits.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Re-reads the open, unmodified documents of `paths` whose files changed on disk, before
|
||||
/// a tool reads or changes them: an agent that just wrote a file itself must not have
|
||||
/// that write undone from a stale buffer because the file watcher has not caught up yet
|
||||
/// (JetBrains refreshes the VFS first). Documents with unsaved changes are left alone.
|
||||
fn refresh_from_disk(project: &Project, paths: &[PathBuf]) {
|
||||
project.documents.on_disk_changed(paths);
|
||||
}
|
||||
|
||||
/// Project files a tool works on: `files` resolved inside the project, existing,
|
||||
/// regular, without duplicates (the first spelling is kept), changeable
|
||||
/// ([`Call::check_writable`]) and with their open documents up to date with the disk.
|
||||
async fn requested_files(call: &Call) -> IdeResult<Vec<(String, PathBuf)>> {
|
||||
let files = call.str_list("files")?;
|
||||
if files.is_empty() {
|
||||
return Err(IdeError::invalid("files must contain at least one path"));
|
||||
}
|
||||
let mut out: Vec<(String, PathBuf)> = Vec::new();
|
||||
for raw in files {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
return Err(IdeError::invalid("files must not contain blank paths"));
|
||||
}
|
||||
let path = call.resolve_inside(raw)?;
|
||||
if !path.exists() {
|
||||
return Err(IdeError::not_found(format!("File not found: {raw}")));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::invalid(format!("Not a file: {raw}")));
|
||||
}
|
||||
if !out.iter().any(|(_, p)| *p == path) {
|
||||
out.push((raw.to_owned(), path));
|
||||
}
|
||||
}
|
||||
for (_, path) in &out {
|
||||
call.check_writable(path).await?;
|
||||
}
|
||||
let (project, paths) = (call.project().clone(), out.iter().map(|(_, p)| p.clone()).collect::<Vec<_>>());
|
||||
call.host()
|
||||
.blocking(move || {
|
||||
refresh_from_disk(&project, &paths);
|
||||
Ok(())
|
||||
})
|
||||
.await?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// --- create_new_file ----------------------------------------------------------------
|
||||
|
||||
fn create_new_file() -> Tool {
|
||||
Tool::new(
|
||||
"create_new_file",
|
||||
"Creates a new file at the specified path within the project directory and optionally populates it with \
|
||||
text if provided.
|
||||
Use this tool to generate new files in your project structure.
|
||||
Note: Creates any necessary parent directories automatically",
|
||||
|call| async move {
|
||||
let raw = call.str("pathInProject")?;
|
||||
let path = call.resolve_writable(&raw).await?;
|
||||
let text = call.opt_str("text")?.map(|t| normalize_newlines(&t));
|
||||
let overwrite = call.flag("overwrite", false)?;
|
||||
let cannot =
|
||||
|e: &dyn std::fmt::Display| IdeError::invalid(format!("Can't create file: {}: {e}", path.display()));
|
||||
if path.is_dir() {
|
||||
return Err(cannot(&"it is a directory"));
|
||||
}
|
||||
if path.exists() && !overwrite {
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("File already exists: {raw}. Specify 'overwrite=true' to overwrite it"),
|
||||
));
|
||||
}
|
||||
create_or_replace(&call, path, text, overwrite).await?;
|
||||
Ok(Output::text(SUCCESS))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required(
|
||||
"pathInProject",
|
||||
ParamType::String,
|
||||
"Path where the file should be created relative to the project root",
|
||||
))
|
||||
.param(Param::optional("text", ParamType::String, "Content to write into the new file"))
|
||||
.param(Param::optional(
|
||||
"overwrite",
|
||||
ParamType::Boolean,
|
||||
"Whether to overwrite an existing file if exists. If false, an exception is thrown in case of a conflict.",
|
||||
))
|
||||
}
|
||||
|
||||
// --- apply_patch --------------------------------------------------------------------
|
||||
|
||||
fn apply_patch() -> Tool {
|
||||
Tool::new(
|
||||
"apply_patch",
|
||||
"Apply a patch using the Codex apply_patch format or unified git diff format.
|
||||
Supports Add, Delete, and Update operations with optional Move to path for updates.
|
||||
Paths must stay inside the project directory.",
|
||||
|call| async move {
|
||||
let input = [call.opt_str("input")?, call.opt_str("patch")?]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.find(|s| !s.trim().is_empty())
|
||||
.ok_or_else(|| IdeError::invalid("input must be a non-empty string"))?;
|
||||
let ops = patch::parse(&input).map_err(IdeError::invalid)?;
|
||||
let total = ops.iter().filter(|op| !matches!(op, FileOp::Skip { .. })).count();
|
||||
let mut applied = 0usize;
|
||||
let mut errors = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
// Each operation stands alone, as in JetBrains: a failed one leaves the others applied.
|
||||
for op in ops {
|
||||
if let FileOp::Skip { path, note } = op {
|
||||
notes.push(format!("{path}: {note}"));
|
||||
continue;
|
||||
}
|
||||
let path = op.path().to_owned();
|
||||
match apply_op(&call, op, &mut notes).await {
|
||||
Ok(()) => applied += 1,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => errors.push(format!("{path}: {}", e.message)),
|
||||
}
|
||||
}
|
||||
let mut out = format!("{applied} out of {total} operations applied.");
|
||||
if !errors.is_empty() {
|
||||
let _ = write!(
|
||||
out,
|
||||
" {} operations failed to be applied due to errors:\n{}",
|
||||
errors.len(),
|
||||
errors.join("\n")
|
||||
);
|
||||
}
|
||||
if !notes.is_empty() {
|
||||
out.push_str("\nNotes:");
|
||||
for n in notes {
|
||||
let _ = write!(out, "\n- {n}");
|
||||
}
|
||||
}
|
||||
Ok(Output::text(out))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::optional(
|
||||
"input",
|
||||
ParamType::String,
|
||||
"Patch text in the apply_patch format or unified git diff format.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"patch",
|
||||
ParamType::String,
|
||||
"Alias of `input` for compatibility with clients that send `{patch: ...}`.",
|
||||
))
|
||||
}
|
||||
|
||||
/// Applies one operation of a patch; its checks come first, so a failed operation changes nothing.
|
||||
async fn apply_op(call: &Call, op: FileOp, notes: &mut Vec<String>) -> IdeResult<()> {
|
||||
let host = call.host();
|
||||
let project = call.project().clone();
|
||||
match op {
|
||||
// Noted by the caller.
|
||||
FileOp::Skip { .. } => Ok(()),
|
||||
FileOp::Add { path: raw, text } => {
|
||||
let path = call.resolve_writable(&raw).await?;
|
||||
if path.exists() {
|
||||
return Err(IdeError::invalid(format!("File already exists: {raw}")));
|
||||
}
|
||||
create_or_replace(call, path, Some(text), false).await
|
||||
}
|
||||
FileOp::Delete { path: raw } => {
|
||||
let path = call.resolve_writable(&raw).await?;
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::not_found(format!("File not found: {raw}")));
|
||||
}
|
||||
if is_dirty(&project, &path) {
|
||||
return Err(IdeError::invalid("cannot delete a file with unsaved editor changes"));
|
||||
}
|
||||
let edit = WorkspaceEdit {
|
||||
files: vec![FileEdit::Delete { path: path.into(), recursive: false }],
|
||||
preconditions: Vec::new(),
|
||||
};
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await
|
||||
}
|
||||
FileOp::Update { path: first, move_to, hunks, or } => {
|
||||
// A plain diff's other names count when its `+++` path does not exist.
|
||||
let mut raw = first.clone();
|
||||
let mut path = call.resolve_inside(&raw)?;
|
||||
for other in &or {
|
||||
if path.exists() {
|
||||
break;
|
||||
}
|
||||
let candidate = call.resolve_inside(other)?;
|
||||
if candidate.exists() {
|
||||
(raw, path) = (other.clone(), candidate);
|
||||
}
|
||||
}
|
||||
if !path.exists() {
|
||||
let tried = if or.is_empty() { String::new() } else { format!(" (also tried {})", or.join(", ")) };
|
||||
return Err(IdeError::not_found(format!("File not found: {first}{tried}")));
|
||||
}
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::invalid(format!("Path is not a file: {raw}")));
|
||||
}
|
||||
if !hunks.is_empty() || move_to.is_some() {
|
||||
call.check_writable(&path).await?;
|
||||
}
|
||||
let to = match &move_to {
|
||||
Some(m) => {
|
||||
let to = call.resolve_writable(m).await?;
|
||||
// A move that changes only the case on a case-insensitive file system finds itself.
|
||||
if to != path && to.exists() && !same_file(&to, &path) {
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("File already exists: {m}"),
|
||||
));
|
||||
}
|
||||
Some(to).filter(|t| *t != path)
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
let shown = call.display(&path);
|
||||
let (p, target) = (project.clone(), path.clone());
|
||||
let (mut edit, fuzzy, dirty) = host
|
||||
.blocking(move || {
|
||||
refresh_from_disk(&p, std::slice::from_ref(&target));
|
||||
let info = p.documents.open(&target)?;
|
||||
let result = (|| {
|
||||
let snap = p.documents.text_snapshot(info.id)?;
|
||||
let dirty = p.documents.is_dirty(info.id)?;
|
||||
let applied = patch::apply(&snap.text, &hunks).map_err(IdeError::invalid)?;
|
||||
let text = ide_text::Document::new(&snap.text);
|
||||
let edits = text
|
||||
.diff(&applied.text)
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit {
|
||||
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
|
||||
new_text: c.insert,
|
||||
})
|
||||
.collect();
|
||||
Ok((
|
||||
WorkspaceEdit {
|
||||
files: vec![FileEdit::Edit {
|
||||
path: target.clone().into(),
|
||||
edits,
|
||||
version: Some(
|
||||
i32::try_from(snap.version)
|
||||
.map_err(|_| IdeError::invalid("document version is out of range"))?,
|
||||
),
|
||||
}],
|
||||
preconditions: Vec::new(),
|
||||
},
|
||||
applied.fuzzy,
|
||||
dirty,
|
||||
))
|
||||
})();
|
||||
p.documents.close(info.id);
|
||||
result
|
||||
})
|
||||
.await?;
|
||||
if let Some(to) = to {
|
||||
edit.files.push(FileEdit::Rename { from: path.into(), to: to.into() });
|
||||
}
|
||||
let edit = crate::ops::lsp::prepare_workspace_edit(&host, call.window(), edit).await?;
|
||||
crate::ops::lsp::apply_workspace_edit(&host, call.window(), edit).await?;
|
||||
if dirty {
|
||||
notes.push(format!("{shown}: patched the unsaved editor buffer"));
|
||||
}
|
||||
for (hunk, fuzz) in fuzzy {
|
||||
notes.push(format!("{shown}: hunk {hunk} matched only {}", fuzz.describe()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- the editor ------------------------------------------------------------------------
|
||||
|
||||
fn open_file_in_editor() -> Tool {
|
||||
Tool::new(
|
||||
"open_file_in_editor",
|
||||
"Opens the specified file in the IDE editor.
|
||||
Requires a filePath parameter containing the path to the file to open.
|
||||
The file path can be absolute or relative to the project root.
|
||||
Optionally puts the caret at a 1-based line and column.",
|
||||
|call| async move {
|
||||
let raw = call.str("filePath")?;
|
||||
// Opening shows the file to the user: outside the project the user agrees first.
|
||||
let path = call.resolve_readable(&raw).await?;
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::not_found(format!("File {raw} doesn't exist or can't be opened")));
|
||||
}
|
||||
let positive = |name: &str| -> IdeResult<Option<u32>> {
|
||||
match call.opt_i64(name)? {
|
||||
None => Ok(None),
|
||||
Some(n) if n < 1 || n > u32::MAX as i64 => {
|
||||
Err(IdeError::invalid(format!("`{name}` is 1-based and must be at least 1")))
|
||||
}
|
||||
Some(n) => Ok(Some(n as u32)),
|
||||
}
|
||||
};
|
||||
let (line, column) = (positive("line")?, positive("column")?);
|
||||
call.ask(McpUiRequest::OpenFile { path: path.into(), line, column }).await?;
|
||||
Ok(Output::text(SUCCESS))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::optional("line", ParamType::Integer, "1-based line to put the caret on"))
|
||||
.param(Param::optional("column", ParamType::Integer, "1-based column on that line"))
|
||||
}
|
||||
@@ -1,380 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/UnifiedDiffSupport.kt), Apache-2.0.
|
||||
//! The unified diff tools answer with, so an agent sees what the IDE changed
|
||||
//! without reading the files again. Capped, so a large change cannot flood the
|
||||
//! agent's context.
|
||||
|
||||
/// Unchanged lines kept around each change, as in a standard unified diff.
|
||||
const CONTEXT_LINES: usize = 3;
|
||||
|
||||
/// The default cap on diff lines.
|
||||
pub const MAX_DIFF_LINES: usize = 200;
|
||||
|
||||
/// The smallest useful hunk: the `@@` header line and one line under it.
|
||||
const MIN_HUNK_LINES: usize = 2;
|
||||
|
||||
/// Beyond this many differing lines (after the common head and tail) the
|
||||
/// diff is not computed: the file is reported as too large to diff.
|
||||
const MAX_EDIT_DISTANCE: usize = 2000;
|
||||
|
||||
/// One file a tool processed: the requested path, the text before and after.
|
||||
/// `None` texts are reported as unavailable, never as "nothing changed".
|
||||
pub struct ChangedFileText {
|
||||
pub path: String,
|
||||
pub before: Option<String>,
|
||||
pub after: Option<String>,
|
||||
}
|
||||
|
||||
/// A unified diff of the changes in `files`, at most `max_lines` diff lines;
|
||||
/// `None` when nothing changed. A file goes in whole, or the footer names it.
|
||||
pub fn build_unified_diff(files: &[ChangedFileText], max_lines: usize) -> Option<String> {
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
let mut omitted_files = Vec::new();
|
||||
let mut omitted_lines = 0usize;
|
||||
for file in files {
|
||||
let Some(diff) = file_diff(file) else { continue };
|
||||
// A header with no hunk under it helps nobody.
|
||||
if !lines.is_empty() && max_lines.saturating_sub(lines.len()) < diff.header.len() + MIN_HUNK_LINES {
|
||||
omitted_files.push(diff.path.clone());
|
||||
omitted_lines += diff.line_count();
|
||||
continue;
|
||||
}
|
||||
lines.extend(diff.header.iter().cloned());
|
||||
for (index, hunk) in diff.hunks.iter().enumerate() {
|
||||
let room = max_lines as isize - lines.len() as isize - 1;
|
||||
if room < 1 && index > 0 {
|
||||
omitted_lines += diff.hunks[index..].iter().map(Hunk::line_count).sum::<usize>();
|
||||
break;
|
||||
}
|
||||
// The first hunk always keeps one line, so its file header never stands alone.
|
||||
let body = room.max(1) as usize;
|
||||
lines.extend(hunk.render(body));
|
||||
omitted_lines += hunk.body.len().saturating_sub(body);
|
||||
}
|
||||
}
|
||||
if lines.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut out = lines.join("\n");
|
||||
if omitted_lines > 0 {
|
||||
out.push_str(&format!("\n… and {omitted_lines} more diff line(s) omitted."));
|
||||
}
|
||||
if !omitted_files.is_empty() {
|
||||
out.push_str(&format!("\n… The diff leaves out these file(s): {}.", omitted_files.join(", ")));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
struct FileDiff {
|
||||
path: String,
|
||||
header: Vec<String>,
|
||||
hunks: Vec<Hunk>,
|
||||
}
|
||||
|
||||
impl FileDiff {
|
||||
fn line_count(&self) -> usize {
|
||||
self.header.len() + self.hunks.iter().map(Hunk::line_count).sum::<usize>()
|
||||
}
|
||||
}
|
||||
|
||||
/// One hunk: where it starts on each side (0-based), and its `-`, `+` and context lines.
|
||||
struct Hunk {
|
||||
start0: usize,
|
||||
start1: usize,
|
||||
body: Vec<String>,
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
fn line_count(&self) -> usize {
|
||||
self.body.len() + 1
|
||||
}
|
||||
|
||||
/// The hunk with at most `max_body` body lines; the header counts the lines it prints.
|
||||
fn render(&self, max_body: usize) -> Vec<String> {
|
||||
let printed = &self.body[..self.body.len().min(max_body)];
|
||||
let count0 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('-')).count();
|
||||
let count1 = printed.iter().filter(|l| l.starts_with(' ') || l.starts_with('+')).count();
|
||||
// An empty range names the line before it, so it keeps the 0-based value.
|
||||
let start = |s: usize, count: usize| if count == 0 { s } else { s + 1 };
|
||||
let mut out =
|
||||
vec![format!("@@ -{},{count0} +{},{count1} @@", start(self.start0, count0), start(self.start1, count1))];
|
||||
out.extend(printed.iter().cloned());
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A line of the text, and whether it ends without a line separator (the last line).
|
||||
type DiffLine<'a> = (&'a str, bool);
|
||||
|
||||
fn file_diff(file: &ChangedFileText) -> Option<FileDiff> {
|
||||
let path = file.path.replace('\\', "/");
|
||||
let header = vec![format!("--- a/{path}"), format!("+++ b/{path}")];
|
||||
let note = |text: &str| {
|
||||
let mut header = header.clone();
|
||||
header.push(format!("({text})"));
|
||||
Some(FileDiff { path: path.clone(), header, hunks: Vec::new() })
|
||||
};
|
||||
let (Some(before), Some(after)) = (&file.before, &file.after) else {
|
||||
return note(
|
||||
"the content of this file is not available, so the diff cannot report what changed; read the file again",
|
||||
);
|
||||
};
|
||||
if before == after {
|
||||
return None;
|
||||
}
|
||||
let old = split_lines(before);
|
||||
let new = split_lines(after);
|
||||
let Some(changes) = changes(&old, &new) else { return note("this file changed, but it is too large to diff") };
|
||||
// The texts differ but the lines are equal: only the line separators changed.
|
||||
if changes.is_empty() {
|
||||
return note("only the line separators of this file changed");
|
||||
}
|
||||
let hunks = group(&changes).into_iter().map(|g| build_hunk(g, &old, &new)).collect();
|
||||
Some(FileDiff { path, header, hunks })
|
||||
}
|
||||
|
||||
/// The lines a diff compares: no extra empty line after a final separator; a
|
||||
/// last line without one is marked, so a change of the final separator alone counts.
|
||||
fn split_lines(text: &str) -> Vec<DiffLine<'_>> {
|
||||
if text.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let ends_with_newline = text.ends_with('\n');
|
||||
let mut lines: Vec<DiffLine> = text.split('\n').map(|l| (l.strip_suffix('\r').unwrap_or(l), false)).collect();
|
||||
if ends_with_newline {
|
||||
lines.pop();
|
||||
} else if let Some(last) = lines.last_mut() {
|
||||
last.1 = true;
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
/// A run of changed lines: at `line0` in the old text `deleted` lines, at `line1` in the new one `inserted`.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
struct Change {
|
||||
line0: usize,
|
||||
line1: usize,
|
||||
deleted: usize,
|
||||
inserted: usize,
|
||||
}
|
||||
|
||||
/// The changes from `a` to `b` (Myers' algorithm), `None` when they differ too much.
|
||||
fn changes(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Change>> {
|
||||
let head = a.iter().zip(b).take_while(|(x, y)| x == y).count();
|
||||
let tail = a[head..].iter().rev().zip(b[head..].iter().rev()).take_while(|(x, y)| x == y).count();
|
||||
let (a_mid, b_mid) = (&a[head..a.len() - tail], &b[head..b.len() - tail]);
|
||||
let script = myers(a_mid, b_mid)?;
|
||||
// `script`: per step, true = equal (both advance), Some side otherwise.
|
||||
let mut out: Vec<Change> = Vec::new();
|
||||
let (mut i, mut j) = (head, head);
|
||||
for step in script {
|
||||
match step {
|
||||
Step::Equal => {
|
||||
i += 1;
|
||||
j += 1;
|
||||
continue;
|
||||
}
|
||||
Step::Delete => {
|
||||
match out.last_mut() {
|
||||
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.deleted += 1,
|
||||
_ => out.push(Change { line0: i, line1: j, deleted: 1, inserted: 0 }),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Step::Insert => {
|
||||
match out.last_mut() {
|
||||
Some(c) if c.line0 + c.deleted == i && c.line1 + c.inserted == j => c.inserted += 1,
|
||||
_ => out.push(Change { line0: i, line1: j, deleted: 0, inserted: 1 }),
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum Step {
|
||||
Equal,
|
||||
Delete,
|
||||
Insert,
|
||||
}
|
||||
|
||||
/// The shortest edit script from `a` to `b`, deletions before insertions in each run.
|
||||
fn myers(a: &[DiffLine], b: &[DiffLine]) -> Option<Vec<Step>> {
|
||||
let (n, m) = (a.len() as isize, b.len() as isize);
|
||||
let max = (n + m) as usize;
|
||||
let limit = max.min(MAX_EDIT_DISTANCE) as isize;
|
||||
let off = max as isize + 1;
|
||||
let mut v = vec![0isize; 2 * max + 3];
|
||||
// V after each round d, for diagonals -d..=d.
|
||||
let mut trace: Vec<Vec<isize>> = Vec::new();
|
||||
let mut end = None;
|
||||
'rounds: for d in 0..=limit {
|
||||
for k in (-d..=d).step_by(2) {
|
||||
let at = |k: isize| (off + k) as usize;
|
||||
let mut x =
|
||||
if k == -d || (k != d && v[at(k - 1)] < v[at(k + 1)]) { v[at(k + 1)] } else { v[at(k - 1)] + 1 };
|
||||
let mut y = x - k;
|
||||
while x < n && y < m && a[x as usize] == b[y as usize] {
|
||||
x += 1;
|
||||
y += 1;
|
||||
}
|
||||
v[at(k)] = x;
|
||||
if x >= n && y >= m {
|
||||
trace.push(v[at(-d)..=at(d)].to_vec());
|
||||
end = Some(d);
|
||||
break 'rounds;
|
||||
}
|
||||
}
|
||||
trace.push(v[(off - d) as usize..=(off + d) as usize].to_vec());
|
||||
}
|
||||
let d_end = end?;
|
||||
let mut steps = Vec::new();
|
||||
let (mut x, mut y) = (n, m);
|
||||
for d in (1..=d_end).rev() {
|
||||
let prev = &trace[(d - 1) as usize];
|
||||
let get = |k: isize| prev[(k + d - 1) as usize];
|
||||
let k = x - y;
|
||||
let insert = k == -d || (k != d && get(k - 1) < get(k + 1));
|
||||
let prev_k = if insert { k + 1 } else { k - 1 };
|
||||
let prev_x = get(prev_k);
|
||||
let prev_y = prev_x - prev_k;
|
||||
let (mid_x, mid_y) = if insert { (prev_x, prev_y + 1) } else { (prev_x + 1, prev_y) };
|
||||
while x > mid_x && y > mid_y {
|
||||
steps.push(Step::Equal);
|
||||
x -= 1;
|
||||
y -= 1;
|
||||
}
|
||||
steps.push(if insert { Step::Insert } else { Step::Delete });
|
||||
(x, y) = (prev_x, prev_y);
|
||||
}
|
||||
steps.extend(std::iter::repeat_n(Step::Equal, x as usize));
|
||||
steps.reverse();
|
||||
// Within a run of changes, deletions first (as `-` lines precede `+` lines).
|
||||
let mut i = 0;
|
||||
while i < steps.len() {
|
||||
if steps[i] == Step::Equal {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let end = steps[i..].iter().position(|s| *s == Step::Equal).map_or(steps.len(), |p| i + p);
|
||||
steps[i..end].sort_by_key(|s| *s == Step::Insert);
|
||||
i = end;
|
||||
}
|
||||
Some(steps)
|
||||
}
|
||||
|
||||
/// Changes whose context lines touch share a hunk.
|
||||
fn group(changes: &[Change]) -> Vec<&[Change]> {
|
||||
let mut out: Vec<&[Change]> = Vec::new();
|
||||
let mut start = 0;
|
||||
for i in 1..changes.len() {
|
||||
let prev = changes[i - 1];
|
||||
if changes[i].line0 - (prev.line0 + prev.deleted) > CONTEXT_LINES * 2 {
|
||||
out.push(&changes[start..i]);
|
||||
start = i;
|
||||
}
|
||||
}
|
||||
if !changes.is_empty() {
|
||||
out.push(&changes[start..]);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn build_hunk(group: &[Change], old: &[DiffLine], new: &[DiffLine]) -> Hunk {
|
||||
let (first, last) = (group[0], group[group.len() - 1]);
|
||||
// One clamp for both sides, so the two starts in the header stay in step.
|
||||
let leading = CONTEXT_LINES.min(first.line0).min(first.line1);
|
||||
let (start0, start1) = (first.line0 - leading, first.line1 - leading);
|
||||
let (end0, end1) = (last.line0 + last.deleted, last.line1 + last.inserted);
|
||||
let trailing = CONTEXT_LINES.min(old.len() - end0).min(new.len() - end1);
|
||||
let mut body = Vec::new();
|
||||
let push = |body: &mut Vec<String>, prefix: char, (text, no_newline): DiffLine| {
|
||||
body.push(format!("{prefix}{text}"));
|
||||
if no_newline {
|
||||
body.push("\\ No newline at end of file".into());
|
||||
}
|
||||
};
|
||||
let mut line0 = start0;
|
||||
for c in group {
|
||||
while line0 < c.line0 {
|
||||
push(&mut body, ' ', old[line0]);
|
||||
line0 += 1;
|
||||
}
|
||||
for &l in &old[c.line0..c.line0 + c.deleted] {
|
||||
push(&mut body, '-', l);
|
||||
}
|
||||
for &l in &new[c.line1..c.line1 + c.inserted] {
|
||||
push(&mut body, '+', l);
|
||||
}
|
||||
line0 = c.line0 + c.deleted;
|
||||
}
|
||||
while line0 < end0 + trailing {
|
||||
push(&mut body, ' ', old[line0]);
|
||||
line0 += 1;
|
||||
}
|
||||
Hunk { start0, start1, body }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn diff(before: &str, after: &str) -> Option<String> {
|
||||
build_unified_diff(
|
||||
&[ChangedFileText { path: "src/A.java".into(), before: Some(before.into()), after: Some(after.into()) }],
|
||||
MAX_DIFF_LINES,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diffs_a_reformat_like_jetbrains() {
|
||||
let out = diff("public class DiffTarget {\nint a;\n}\n", "public class DiffTarget {\n int a;\n}\n").unwrap();
|
||||
assert_eq!(
|
||||
out,
|
||||
"--- a/src/A.java\n+++ b/src/A.java\n@@ -1,3 +1,3 @@\n public class DiffTarget {\n-int a;\n+ int a;\n }"
|
||||
);
|
||||
assert_eq!(diff("same\n", "same\n"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_far_changes_into_hunks_and_marks_the_final_newline() {
|
||||
let before: String = (1..=20).map(|i| format!("{i}\n")).collect();
|
||||
let after: String = (1..=20)
|
||||
.map(|i| match i {
|
||||
2 => "two\n".to_owned(),
|
||||
19 => "nineteen\n".to_owned(),
|
||||
i => format!("{i}\n"),
|
||||
})
|
||||
.collect();
|
||||
let out = diff(&before, &after).unwrap();
|
||||
assert!(out.contains("@@ -1,5 +1,5 @@\n 1\n-2\n+two\n 3\n 4\n 5\n"), "{out}");
|
||||
assert!(out.contains("@@ -16,5 +16,5 @@\n 16\n 17\n 18\n-19\n+nineteen\n 20"), "{out}");
|
||||
let out = diff("a\nb", "a\nb\n").unwrap();
|
||||
assert!(out.ends_with("@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b"), "{out}");
|
||||
let out = diff("a\r\n", "a\n").unwrap();
|
||||
assert!(out.ends_with("(only the line separators of this file changed)"), "{out}");
|
||||
let out = diff("", "x\n").unwrap();
|
||||
assert!(out.ends_with("@@ -0,0 +1,1 @@\n+x"), "{out}");
|
||||
let out = diff("import a;\n\nclass X {}\n", "\nclass X {}\n").unwrap();
|
||||
assert!(out.ends_with("@@ -1,3 +1,2 @@\n-import a;\n \n class X {}"), "{out}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caps_the_diff_and_names_what_it_leaves_out() {
|
||||
let before: String = (0..300).map(|i| format!("{i}\n")).collect();
|
||||
let after: String = (0..300).map(|i| format!("x{i}\n")).collect();
|
||||
let files = [
|
||||
ChangedFileText { path: "a.txt".into(), before: Some(before.clone()), after: Some(after.clone()) },
|
||||
ChangedFileText { path: "b.txt".into(), before: Some(before), after: Some(after) },
|
||||
ChangedFileText { path: "c.bin".into(), before: None, after: None },
|
||||
];
|
||||
let out = build_unified_diff(&files, 50).unwrap();
|
||||
assert_eq!(out.lines().filter(|l| !l.starts_with('…')).count(), 50, "{out}");
|
||||
// 553 lines of a.txt's hunk, 603 of b.txt, 3 of c.bin's note.
|
||||
assert!(out.contains("… and 1159 more diff line(s) omitted."), "{out}");
|
||||
assert!(out.ends_with("… The diff leaves out these file(s): b.txt, c.bin."), "{out}");
|
||||
// The cut hunk's header counts what it prints.
|
||||
assert!(out.contains("@@ -1,47 +0,0 @@"), "{out}");
|
||||
}
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/FormattingToolset.kt), Apache-2.0.
|
||||
//! `reformat_file` and `optimize_imports` with the language servers: document
|
||||
//! formatting and the `source.organizeImports` code action. Both answer with a
|
||||
//! unified diff of what changed, or `ok`.
|
||||
//!
|
||||
//! JetBrains always has a formatter and an import optimizer; here a file whose
|
||||
//! language server offers none is reported after the diff (the whole call fails
|
||||
//! only when no file could be processed).
|
||||
|
||||
use super::diff::{ChangedFileText, MAX_DIFF_LINES, build_unified_diff};
|
||||
use super::{edits_for, open_document, requested_files, whole_range};
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{DocId, IdeError, IdeResult};
|
||||
use std::path::Path;
|
||||
|
||||
const ORGANIZE_IMPORTS: &str = "source.organizeImports";
|
||||
|
||||
pub(super) fn reformat_file() -> Tool {
|
||||
Tool::new(
|
||||
"reformat_file",
|
||||
"Reformats the specified files in the IDE.
|
||||
Use this tool to apply code formatting rules to files identified by their project-relative paths.
|
||||
Returns a unified diff of the changes that the formatter applied, or \"ok\" when the files already match the code \
|
||||
style.
|
||||
The diff shows the formatter changes only. It never repeats the edit that you applied yourself.
|
||||
The diff stops at 200 lines. A footer names the file(s) that it leaves out.
|
||||
To remove unused imports without reformatting, use optimize_imports.",
|
||||
|call| async move {
|
||||
let organize = call.flag("optimizeImports", false)?;
|
||||
run(&call, Job { organize, format: true }).await
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::required(
|
||||
"files",
|
||||
ParamType::StringList,
|
||||
"List of project-relative files to reformat. Duplicate paths are ignored after normalization.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"optimizeImports",
|
||||
ParamType::Boolean,
|
||||
"If true, also remove unused imports and order the remaining ones before reformatting.",
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct Job {
|
||||
organize: bool,
|
||||
format: bool,
|
||||
}
|
||||
|
||||
async fn run(call: &Call, job: Job) -> IdeResult<Output> {
|
||||
let files = requested_files(call).await?;
|
||||
let mut changed = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for (raw, path) in &files {
|
||||
match process(call, path, job).await {
|
||||
Ok(outcome) => {
|
||||
changed.push(ChangedFileText {
|
||||
path: raw.clone(),
|
||||
before: Some(outcome.before),
|
||||
after: Some(outcome.after),
|
||||
});
|
||||
problems.extend(outcome.notes.into_iter().map(|n| format!("{raw}: {n}")));
|
||||
}
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => {
|
||||
let what = if job.format { "not formatted" } else { "imports not optimized" };
|
||||
problems.push(format!("{raw}: {what}: {}", e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(IdeError::unavailable(problems.join("\n")));
|
||||
}
|
||||
let mut out = build_unified_diff(&changed, MAX_DIFF_LINES).unwrap_or_else(|| "ok".into());
|
||||
if !problems.is_empty() {
|
||||
out.push_str("\n\n");
|
||||
out.push_str(&problems.join("\n"));
|
||||
}
|
||||
Ok(Output::text(out))
|
||||
}
|
||||
|
||||
struct Outcome {
|
||||
before: String,
|
||||
after: String,
|
||||
/// What went wrong without failing the file (imports not optimized before a reformat).
|
||||
notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// Runs the job on one file's document (opened transiently when it is not open) and saves it.
|
||||
async fn process(call: &Call, path: &Path, job: Job) -> IdeResult<Outcome> {
|
||||
let host = call.host();
|
||||
let project = call.project().clone();
|
||||
let (p, target) = (project.clone(), path.to_path_buf());
|
||||
let (doc, transient) = host.blocking(move || open_document(&p, &target)).await?;
|
||||
let result = async {
|
||||
let info = project.documents.snapshot(doc)?;
|
||||
if info.read_only {
|
||||
return Err(IdeError::invalid("the file is read-only"));
|
||||
}
|
||||
let before = project.documents.text_snapshot(doc)?.text;
|
||||
let mut notes = Vec::new();
|
||||
if job.organize {
|
||||
match organize_imports(call, doc, path).await {
|
||||
Ok(()) => {}
|
||||
Err(e) if job.format && e.kind != ide_core::error::ErrorKind::Cancelled => {
|
||||
notes.push(format!("imports not optimized: {}", e.message));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
if job.format {
|
||||
format(call, doc, &info).await?;
|
||||
}
|
||||
let p = project.clone();
|
||||
let after = host
|
||||
.blocking(move || {
|
||||
if !info.dirty && p.documents.is_dirty(doc)? {
|
||||
p.documents.save(doc)?;
|
||||
}
|
||||
Ok(p.documents.text_snapshot(doc)?.text)
|
||||
})
|
||||
.await?;
|
||||
Ok(Outcome { before, after, notes })
|
||||
}
|
||||
.await;
|
||||
if transient {
|
||||
project.documents.close(doc);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Formats the whole document with its language server; indentation from EditorConfig, else the settings.
|
||||
async fn format(call: &Call, doc: DocId, info: &ide_workspace::DocumentInfo) -> IdeResult<()> {
|
||||
let host = call.host();
|
||||
let project = call.project().clone();
|
||||
let editor = project.settings.effective().editor;
|
||||
let ec = info.editorconfig.as_ref();
|
||||
let insert_spaces = match ec.and_then(|e| e.indent_style) {
|
||||
Some(style) => style != ide_workspace::editorconfig::IndentStyle::Tab,
|
||||
None => editor.insert_spaces,
|
||||
};
|
||||
let tab_size = ec.and_then(|e| e.indent_size.or(e.tab_width)).unwrap_or(editor.tab_size);
|
||||
let options = ide_lsp::types::FormatOptions { tab_size, insert_spaces, on_save: false, tool: None };
|
||||
let version = project.documents.text_snapshot(doc)?.version;
|
||||
let edits = tokio::select! {
|
||||
r = crate::ops::lsp::lsp_format(&host, call.window(), doc, None, options) => r?,
|
||||
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
host.blocking(move || {
|
||||
if project.documents.text_snapshot(doc)?.version != version {
|
||||
return Err(IdeError::unavailable("the file changed while it was being formatted; try again"));
|
||||
}
|
||||
project.documents.apply_edits(doc, &edits).map(|_| ())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Runs the `source.organizeImports` code action of the document's language server.
|
||||
/// Nothing offered: nothing to do, unless the server says it has no such action at all.
|
||||
pub(super) async fn organize_imports(call: &Call, doc: DocId, path: &Path) -> IdeResult<()> {
|
||||
let host = call.host();
|
||||
let window = call.window();
|
||||
let text = call.project().documents.text_snapshot(doc)?.text;
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let only = vec![ORGANIZE_IMPORTS.to_owned()];
|
||||
let query = tokio::select! {
|
||||
r = crate::ops::lsp::lsp_code_action_query(&host, window, request, doc, whole_range(&text), only) => r?,
|
||||
_ = call.cancel.cancelled() => {
|
||||
host.cancel_request(request);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
};
|
||||
let organizes = |kind: &str| kind == ORGANIZE_IMPORTS || kind.starts_with("source.organizeImports.");
|
||||
let action = query
|
||||
.actions
|
||||
.into_iter()
|
||||
.filter(|a| a.disabled_reason.is_none() && a.kind.as_deref().is_some_and(organizes))
|
||||
.max_by_key(|a| a.is_preferred);
|
||||
let Some(action) = action else {
|
||||
let offered = query.advertised_kinds.is_none_or(|kinds| kinds.iter().any(|k| k == "source" || organizes(k)));
|
||||
return if offered {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("the language server of this file offers no organize imports action"))
|
||||
};
|
||||
};
|
||||
let action = crate::ops::lsp::lsp_resolve_code_action(&host, window, doc, action).await?;
|
||||
match &action.edit {
|
||||
Some(edit) => {
|
||||
let edits = edits_for(edit, path);
|
||||
let project = call.project().clone();
|
||||
host.blocking(move || project.documents.apply_edits(doc, &edits).map(|_| ())).await
|
||||
}
|
||||
// The server applies the change itself (`workspace/applyEdit` during the command).
|
||||
None => crate::ops::lsp::lsp_apply_code_action(&host, window, doc, action).await,
|
||||
}
|
||||
}
|
||||
@@ -1,949 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/PatchApplyEngine.kt), Apache-2.0.
|
||||
//! Patches as AI agents write them: the Codex `apply_patch` format (with the
|
||||
//! strict `@@` pair blocks some models write) and unified (git) diffs, bare or
|
||||
//! inside `*** Begin Patch`. Parsing and applying hunks to a text are pure, so
|
||||
//! they are tested here; the tool in `edit.rs` resolves paths and writes through
|
||||
//! the documents.
|
||||
//!
|
||||
//! Beyond JetBrains: stacked `@@` anchors, unified hunk line numbers as hints,
|
||||
//! `\ No newline at end of file`, plain `diff -u` / `diff -ru` names, and errors
|
||||
//! that show where a hunk went wrong.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
/// One file of a patch, paths as written in it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOp {
|
||||
Add {
|
||||
path: String,
|
||||
text: String,
|
||||
},
|
||||
Delete {
|
||||
path: String,
|
||||
},
|
||||
/// `or` lists other paths the file may have, tried in order when `path` does
|
||||
/// not exist (a plain diff's `---` name, or its names without their first
|
||||
/// directory, as `patch -p1` reads `diff -ru old/ new/`).
|
||||
Update {
|
||||
path: String,
|
||||
move_to: Option<String>,
|
||||
hunks: Vec<Hunk>,
|
||||
or: Vec<String>,
|
||||
},
|
||||
/// A git section the tool does not apply (a copy): skipped with `note`, the others go on.
|
||||
Skip {
|
||||
path: String,
|
||||
note: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl FileOp {
|
||||
pub fn path(&self) -> &str {
|
||||
match self {
|
||||
FileOp::Add { path, .. }
|
||||
| FileOp::Delete { path }
|
||||
| FileOp::Update { path, .. }
|
||||
| FileOp::Skip { path, .. } => path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Hunk {
|
||||
/// `@@ <line>` anchors: found in order before the hunk's lines.
|
||||
pub anchors: Vec<String>,
|
||||
/// `@@ -a,b +c,d @@`: the 1-based start in the old file, a hint where to look.
|
||||
pub old_start: Option<usize>,
|
||||
pub lines: Vec<Line>,
|
||||
/// `*** End of File`: the lines end the file.
|
||||
pub at_eof: bool,
|
||||
/// `\ No newline at end of file` after the old / new side.
|
||||
pub old_no_newline: bool,
|
||||
pub new_no_newline: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Line {
|
||||
Context(String),
|
||||
Remove(String),
|
||||
Add(String),
|
||||
}
|
||||
|
||||
impl Hunk {
|
||||
fn old_lines(&self) -> Vec<&str> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter_map(|l| match l {
|
||||
Line::Context(s) | Line::Remove(s) => Some(s.as_str()),
|
||||
Line::Add(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn new_lines(&self) -> Vec<&str> {
|
||||
self.lines
|
||||
.iter()
|
||||
.filter_map(|l| match l {
|
||||
Line::Context(s) | Line::Add(s) => Some(s.as_str()),
|
||||
Line::Remove(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// How the hunk is named in errors.
|
||||
fn label(&self, n: usize) -> String {
|
||||
match (self.anchors.last(), self.old_start) {
|
||||
(Some(a), _) => format!("hunk {n} (@@ {a})"),
|
||||
(None, Some(s)) => format!("hunk {n} (at line {s})"),
|
||||
_ => format!("hunk {n}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BEGIN_PATCH: &str = "*** Begin Patch";
|
||||
const END_PATCH: &str = "*** End Patch";
|
||||
const ADD: &str = "*** Add File: ";
|
||||
const DELETE: &str = "*** Delete File: ";
|
||||
const UPDATE: &str = "*** Update File: ";
|
||||
const MOVE: &str = "*** Move to: ";
|
||||
const END_OF_FILE: &str = "*** End of File";
|
||||
const DIFF_GIT: &str = "diff --git ";
|
||||
|
||||
/// Parses either format: `*** Begin Patch` … `*** End Patch` (holding the Codex
|
||||
/// format or a git diff), else a bare unified diff.
|
||||
pub fn parse(input: &str) -> Result<Vec<FileOp>, String> {
|
||||
let lines: Vec<&str> = input.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
let ops = match lines.iter().position(|l| l.trim() == BEGIN_PATCH) {
|
||||
Some(begin) => {
|
||||
let end = lines[begin + 1..]
|
||||
.iter()
|
||||
.position(|l| l.trim() == END_PATCH)
|
||||
.map(|e| begin + 1 + e)
|
||||
.ok_or("patch must include *** End Patch (the patch looks truncated)")?;
|
||||
let body = &lines[begin + 1..end];
|
||||
// `--- x` also removes a `-- x` line (SQL, Lua comments): only a body without
|
||||
// Codex headers is read as a diff.
|
||||
let codex = body.iter().any(|l| {
|
||||
let t = l.trim_start();
|
||||
t.starts_with(ADD) || t.starts_with(DELETE) || t.starts_with(UPDATE)
|
||||
});
|
||||
if !codex && looks_like_git_diff(body) {
|
||||
parse_unified(body, begin + 1)?
|
||||
} else {
|
||||
parse_codex(body, begin + 1)?
|
||||
}
|
||||
}
|
||||
None if looks_like_git_diff(&lines) => parse_unified(&lines, 0)?,
|
||||
None => {
|
||||
return Err("patch must include *** Begin Patch (or be a unified diff with `--- a/path` and \
|
||||
`+++ b/path` headers)"
|
||||
.into());
|
||||
}
|
||||
};
|
||||
if ops.is_empty() {
|
||||
return Err("patch did not contain any operations".into());
|
||||
}
|
||||
Ok(ops)
|
||||
}
|
||||
|
||||
fn looks_like_git_diff(lines: &[&str]) -> bool {
|
||||
let mut markers = false;
|
||||
for line in lines {
|
||||
let t = line.trim_start();
|
||||
if t.starts_with(DIFF_GIT) || t.starts_with("rename from ") || t.starts_with("rename to ") {
|
||||
return true;
|
||||
}
|
||||
markers |= line.starts_with("--- ") || line.starts_with("+++ ");
|
||||
}
|
||||
markers
|
||||
}
|
||||
|
||||
/// Refuses paths with control characters, or with `\n`-style escapes a model wrote
|
||||
/// instead of a real path.
|
||||
fn ensure_safe_path(path: &str, label: &str) -> Result<(), String> {
|
||||
let control = path.chars().any(|c| c.is_ascii_control());
|
||||
let escaped = ["\\n", "\\r", "\\t"].iter().any(|e| path.contains(e));
|
||||
if control || escaped {
|
||||
Err(format!("{label} path contains control characters or escape sequences"))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// --- the Codex format -----------------------------------------------------------
|
||||
|
||||
fn is_diff_prefix(c: char) -> bool {
|
||||
matches!(c, ' ' | '+' | '-')
|
||||
}
|
||||
|
||||
/// `*** Add File:` and the like (not `*** End of File`, which belongs to a hunk).
|
||||
fn is_patch_header(line: &str) -> bool {
|
||||
match line.chars().next() {
|
||||
None => false,
|
||||
Some(c) if is_diff_prefix(c) => false,
|
||||
Some(_) => {
|
||||
let t = line.trim_start();
|
||||
t.starts_with("*** ") && t.trim_end() != END_OF_FILE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hunk_header(line: &str) -> bool {
|
||||
line.chars().next().is_some_and(|c| !is_diff_prefix(c)) && line.trim_start().starts_with("@@")
|
||||
}
|
||||
|
||||
fn op_path(rest: &str, label: &str) -> Result<String, String> {
|
||||
let path = rest.trim();
|
||||
if path.is_empty() {
|
||||
return Err(format!("{label} requires a path"));
|
||||
}
|
||||
ensure_safe_path(path, label)?;
|
||||
Ok(path.to_owned())
|
||||
}
|
||||
|
||||
/// `lines[start..]` is the body after `*** Begin Patch`; `offset` numbers lines in errors.
|
||||
fn parse_codex(body: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
|
||||
let mut ops = Vec::new();
|
||||
let mut i = 0;
|
||||
let n = |i: usize| i + offset + 1;
|
||||
while i < body.len() {
|
||||
let line = body[i];
|
||||
let header = line.trim_start();
|
||||
if let Some(rest) = header.strip_prefix(ADD) {
|
||||
let path = op_path(rest, "Add File")?;
|
||||
i += 1;
|
||||
// (text, written with `+`): blank separator lines before the next header are dropped.
|
||||
let mut content: Vec<(&str, bool)> = Vec::new();
|
||||
while let Some(&l) = body.get(i).filter(|l| !is_patch_header(l)) {
|
||||
match l.strip_prefix('+') {
|
||||
Some(text) => content.push((text, true)),
|
||||
None if l.trim().is_empty() => content.push(("", false)),
|
||||
None => return Err(format!("Add File lines must start with + (line {}: `{l}`)", n(i))),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
while content.last().is_some_and(|(_, explicit)| !explicit) {
|
||||
content.pop();
|
||||
}
|
||||
let mut text = content.iter().map(|(t, _)| *t).collect::<Vec<_>>().join("\n");
|
||||
if !content.is_empty() {
|
||||
text.push('\n');
|
||||
}
|
||||
ops.push(FileOp::Add { path, text });
|
||||
} else if let Some(rest) = header.strip_prefix(DELETE) {
|
||||
ops.push(FileOp::Delete { path: op_path(rest, "Delete File")? });
|
||||
i += 1;
|
||||
} else if let Some(rest) = header.strip_prefix(UPDATE) {
|
||||
let path = op_path(rest, "Update File")?;
|
||||
i += 1;
|
||||
let mut move_to = None;
|
||||
if let Some(rest) =
|
||||
body.get(i).filter(|l| is_patch_header(l)).and_then(|l| l.trim_start().strip_prefix(MOVE))
|
||||
{
|
||||
move_to = Some(op_path(rest, "Move to")?);
|
||||
i += 1;
|
||||
}
|
||||
let mut hunks = Vec::new();
|
||||
while i < body.len() && !is_patch_header(body[i]) {
|
||||
if body[i].trim().is_empty() {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
hunks.push(parse_codex_hunk(body, &mut i, hunks.is_empty(), offset)?);
|
||||
}
|
||||
// A move alone needs no hunk (JetBrains requires one).
|
||||
if hunks.is_empty() && move_to.is_none() {
|
||||
return Err("Update File requires at least one hunk".into());
|
||||
}
|
||||
ops.push(FileOp::Update { path, move_to, hunks, or: Vec::new() });
|
||||
} else if line.trim().is_empty() {
|
||||
i += 1;
|
||||
} else {
|
||||
return Err(format!("Unexpected patch line: {line}"));
|
||||
}
|
||||
}
|
||||
Ok(ops)
|
||||
}
|
||||
|
||||
fn parse_codex_hunk(body: &[&str], i: &mut usize, first: bool, offset: usize) -> Result<Hunk, String> {
|
||||
let mut hunk = Hunk::default();
|
||||
let mut bare = false;
|
||||
let mut headed = false;
|
||||
// Several `@@` lines narrow the place down, as in Codex.
|
||||
while let Some(&line) = body.get(*i).filter(|l| is_hunk_header(l)) {
|
||||
let trimmed = line.trim();
|
||||
match parse_range_header(trimmed) {
|
||||
Some((start, ..)) => hunk.old_start = Some(start),
|
||||
None => {
|
||||
let text = trimmed.trim_start_matches('@').trim();
|
||||
// `@@ fn main() @@` repeats the marker at the end sometimes.
|
||||
let text = text.trim_end_matches('@').trim_end();
|
||||
if !text.is_empty() {
|
||||
hunk.anchors.push(text.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
bare = trimmed == "@@";
|
||||
headed = true;
|
||||
*i += 1;
|
||||
}
|
||||
let n = |i: usize| i + offset + 1;
|
||||
if !headed {
|
||||
let line = body[*i];
|
||||
if !first || !line.chars().next().is_none_or(is_diff_prefix) {
|
||||
return Err(format!("Expected @@ hunk header (line {}: `{line}`)", n(*i)));
|
||||
}
|
||||
}
|
||||
let is_strict_start =
|
||||
|l: &str| !l.is_empty() && !is_patch_header(l) && !is_hunk_header(l) && !l.starts_with(is_diff_prefix);
|
||||
if bare && body.get(*i).is_some_and(|l| is_strict_start(l)) {
|
||||
return parse_strict_pair(body, i);
|
||||
}
|
||||
// Blank lines at the end of a hunk are usually separators, not context.
|
||||
let mut implicit_blanks = 0usize;
|
||||
while let Some(&line) = body.get(*i).filter(|l| !is_hunk_header(l) && !is_patch_header(l)) {
|
||||
if line.trim_end() == END_OF_FILE {
|
||||
hunk.at_eof = true;
|
||||
*i += 1;
|
||||
break;
|
||||
}
|
||||
let parsed = match line.chars().next() {
|
||||
None => Line::Context(String::new()),
|
||||
Some(' ') => Line::Context(line[1..].to_owned()),
|
||||
Some('-') => Line::Remove(line[1..].to_owned()),
|
||||
Some('+') => Line::Add(line[1..].to_owned()),
|
||||
Some(_) if hunk.lines.is_empty() => {
|
||||
return Err(format!("Hunk lines must start with space, +, or - (line {}: `{line}`)", n(*i)));
|
||||
}
|
||||
Some(_) => break,
|
||||
};
|
||||
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
|
||||
hunk.lines.push(parsed);
|
||||
*i += 1;
|
||||
}
|
||||
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
|
||||
if hunk.lines.is_empty() {
|
||||
return Err("Empty hunk in Update File".into());
|
||||
}
|
||||
Ok(hunk)
|
||||
}
|
||||
|
||||
/// `@@`, the old lines, `@@`, the new lines: whole lines without `-` / `+` prefixes.
|
||||
fn parse_strict_pair(body: &[&str], i: &mut usize) -> Result<Hunk, String> {
|
||||
let mut old = Vec::new();
|
||||
let mut closed = false;
|
||||
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l)) {
|
||||
*i += 1;
|
||||
if line.trim() == "@@" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
old.push(line);
|
||||
}
|
||||
if !closed {
|
||||
return Err("Strict @@ pair hunk requires second @@ delimiter".into());
|
||||
}
|
||||
let mut new = Vec::new();
|
||||
while let Some(&line) = body.get(*i).filter(|l| !is_patch_header(l) && !is_hunk_header(l)) {
|
||||
new.push(line);
|
||||
*i += 1;
|
||||
}
|
||||
while new.last().is_some_and(|l| l.trim().is_empty()) {
|
||||
new.pop();
|
||||
}
|
||||
if old.is_empty() && new.is_empty() {
|
||||
return Err("Empty hunk in Update File".into());
|
||||
}
|
||||
let lines = old
|
||||
.into_iter()
|
||||
.map(|l| Line::Remove(l.to_owned()))
|
||||
.chain(new.into_iter().map(|l| Line::Add(l.to_owned())))
|
||||
.collect();
|
||||
Ok(Hunk { lines, ..Hunk::default() })
|
||||
}
|
||||
|
||||
// --- unified diffs -------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct Section {
|
||||
git_old: Option<String>,
|
||||
git_new: Option<String>,
|
||||
/// `---` / `+++` seen; `None` inside is `/dev/null`.
|
||||
old: Option<Option<String>>,
|
||||
new: Option<Option<String>>,
|
||||
new_file: bool,
|
||||
deleted: bool,
|
||||
rename_from: Option<String>,
|
||||
rename_to: Option<String>,
|
||||
/// `old mode` / `new mode`: a permission change, which the tool leaves alone.
|
||||
mode_change: bool,
|
||||
/// `copy from` / `copy to`.
|
||||
copy_from: Option<String>,
|
||||
copy_to: Option<String>,
|
||||
hunks: Vec<Hunk>,
|
||||
line: usize,
|
||||
}
|
||||
|
||||
fn is_file_start(lines: &[&str], i: usize) -> bool {
|
||||
let l = lines[i];
|
||||
l.starts_with(DIFF_GIT)
|
||||
|| (l.starts_with("--- ") && lines.get(i + 1).is_some_and(|n| n.starts_with("+++ ")) && l != "--- ")
|
||||
}
|
||||
|
||||
/// `offset` numbers lines in errors (the diff may sit inside `*** Begin Patch`).
|
||||
fn parse_unified(lines: &[&str], offset: usize) -> Result<Vec<FileOp>, String> {
|
||||
let mut ops = Vec::new();
|
||||
let mut i = 0;
|
||||
let mut section: Option<Section> = None;
|
||||
while i < lines.len() {
|
||||
let line = lines[i];
|
||||
let n = i + offset + 1;
|
||||
if is_file_start(lines, i) && (line.starts_with(DIFF_GIT) || section.as_ref().is_none_or(|s| s.old.is_some())) {
|
||||
if let Some(s) = section.take() {
|
||||
ops.push(finish_section(s)?);
|
||||
}
|
||||
let mut s = Section { line: n, ..Section::default() };
|
||||
if let Some(rest) = line.strip_prefix(DIFF_GIT) {
|
||||
(s.git_old, s.git_new) = split_git_paths(rest);
|
||||
i += 1;
|
||||
}
|
||||
section = Some(s);
|
||||
continue;
|
||||
}
|
||||
let Some(s) = section.as_mut() else {
|
||||
if line.starts_with("@@ ") {
|
||||
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
|
||||
}
|
||||
// Text before the first file (a commit message, `Index:` lines).
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
let trimmed = line.trim_start();
|
||||
if let Some(p) = line.strip_prefix("--- ") {
|
||||
s.old = Some(diff_path(p, "a/"));
|
||||
} else if let Some(p) = line.strip_prefix("+++ ") {
|
||||
s.new = Some(diff_path(p, "b/"));
|
||||
} else if trimmed.starts_with("new file mode") {
|
||||
s.new_file = true;
|
||||
} else if trimmed.starts_with("deleted file mode") {
|
||||
s.deleted = true;
|
||||
} else if let Some(p) = trimmed.strip_prefix("rename from ") {
|
||||
s.rename_from = Some(rename_path(p)?);
|
||||
} else if let Some(p) = trimmed.strip_prefix("rename to ") {
|
||||
s.rename_to = Some(rename_path(p)?);
|
||||
} else if trimmed.starts_with("Binary files ") || trimmed == "GIT binary patch" {
|
||||
return Err(format!("Binary git patch is not supported (line {n})"));
|
||||
} else if let Some(p) = trimmed.strip_prefix("copy from ") {
|
||||
s.copy_from = Some(rename_path(p)?);
|
||||
} else if let Some(p) = trimmed.strip_prefix("copy to ") {
|
||||
s.copy_to = Some(rename_path(p)?);
|
||||
} else if trimmed.starts_with("old mode ") || trimmed.starts_with("new mode ") {
|
||||
s.mode_change = true;
|
||||
} else if line.starts_with("@@") {
|
||||
if s.old.is_none() && s.new.is_none() && s.git_old.is_none() {
|
||||
return Err(format!("line {n}: a hunk without a `--- a/path` / `+++ b/path` header before it"));
|
||||
}
|
||||
i += 1;
|
||||
let hunk = parse_unified_hunk(lines, &mut i, line, n)?;
|
||||
s.hunks.push(hunk);
|
||||
continue;
|
||||
} else if !s.hunks.is_empty() && (line.starts_with('+') || line.starts_with('-') || line.starts_with(' ')) {
|
||||
// Lines after a hunk that its counts left out (`-- ` ends `git format-patch` output).
|
||||
if line != "-- " {
|
||||
return Err(format!(
|
||||
"line {n}: `{line}` follows a hunk but is not part of it; check the hunk's counts"
|
||||
));
|
||||
}
|
||||
if let Some(s) = section.take() {
|
||||
ops.push(finish_section(s)?);
|
||||
}
|
||||
}
|
||||
// Other extended headers (`index`, modes, similarity) need nothing.
|
||||
i += 1;
|
||||
}
|
||||
if let Some(s) = section {
|
||||
ops.push(finish_section(s)?);
|
||||
}
|
||||
Ok(ops)
|
||||
}
|
||||
|
||||
/// `@@ -a,b +c,d @@`: hunk lines until the counts are used up; lines beyond
|
||||
/// counts that are too small are taken too, and a bare `@@` reads to the next header.
|
||||
fn parse_unified_hunk(lines: &[&str], i: &mut usize, header: &str, n: usize) -> Result<Hunk, String> {
|
||||
let counts = parse_range_header(header);
|
||||
if counts.is_none() && header.starts_with("@@ -") {
|
||||
return Err(format!("line {n}: cannot read the hunk header `{header}`"));
|
||||
}
|
||||
let mut hunk = Hunk { old_start: counts.map(|c| c.0), ..Hunk::default() };
|
||||
let (mut old_left, mut new_left) = counts.map_or((usize::MAX, usize::MAX), |c| (c.1, c.2));
|
||||
let mut implicit_blanks = 0usize;
|
||||
let first = *i;
|
||||
while let Some(&line) = lines.get(*i) {
|
||||
let done = old_left == 0 && new_left == 0;
|
||||
if line.starts_with("@@") || is_file_start(lines, *i) {
|
||||
break;
|
||||
}
|
||||
if line.trim_end() == END_OF_FILE {
|
||||
hunk.at_eof = true;
|
||||
*i += 1;
|
||||
break;
|
||||
}
|
||||
if line.starts_with('\\') {
|
||||
match hunk.lines.last() {
|
||||
Some(Line::Remove(_)) => hunk.old_no_newline = true,
|
||||
Some(Line::Add(_)) => hunk.new_no_newline = true,
|
||||
Some(Line::Context(_)) => {
|
||||
hunk.old_no_newline = true;
|
||||
hunk.new_no_newline = true;
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
*i += 1;
|
||||
continue;
|
||||
}
|
||||
let parsed = match line.chars().next() {
|
||||
_ if done && (line.is_empty() || line == "-- ") => break,
|
||||
Some(' ') => Line::Context(line[1..].to_owned()),
|
||||
Some('-') => Line::Remove(line[1..].to_owned()),
|
||||
Some('+') => Line::Add(line[1..].to_owned()),
|
||||
// Editors strip the space of empty context lines.
|
||||
None => Line::Context(String::new()),
|
||||
Some(_) if hunk.lines.is_empty() => {
|
||||
return Err(format!(
|
||||
"Hunk lines must start with space, +, or - (line {}: `{line}`)",
|
||||
n + 1 + *i - first
|
||||
));
|
||||
}
|
||||
Some(_) => break,
|
||||
};
|
||||
match parsed {
|
||||
Line::Context(_) => {
|
||||
old_left = old_left.saturating_sub(1);
|
||||
new_left = new_left.saturating_sub(1);
|
||||
}
|
||||
Line::Remove(_) => old_left = old_left.saturating_sub(1),
|
||||
Line::Add(_) => new_left = new_left.saturating_sub(1),
|
||||
}
|
||||
implicit_blanks = if line.is_empty() { implicit_blanks + 1 } else { 0 };
|
||||
hunk.lines.push(parsed);
|
||||
*i += 1;
|
||||
}
|
||||
if counts.is_none() {
|
||||
hunk.lines.truncate(hunk.lines.len() - implicit_blanks);
|
||||
}
|
||||
if hunk.lines.is_empty() {
|
||||
return Err(format!("Empty hunk in Update File (line {n}: `{header}`)"));
|
||||
}
|
||||
Ok(hunk)
|
||||
}
|
||||
|
||||
/// (old start, old count, new count) of `@@ -a[,b] +c[,d] @@`; extra `@`s (`@@@`) are accepted.
|
||||
fn parse_range_header(header: &str) -> Option<(usize, usize, usize)> {
|
||||
let rest = header.trim_start_matches('@').trim_start().strip_prefix('-')?;
|
||||
let (ranges, _) = rest.split_once(" @@").unwrap_or((rest.trim_end_matches('@'), ""));
|
||||
let (old, new) = ranges.split_once(" +")?;
|
||||
let range = |r: &str| -> Option<(usize, usize)> {
|
||||
match r.trim().split_once(',') {
|
||||
Some((s, c)) => Some((s.parse().ok()?, c.parse().ok()?)),
|
||||
None => Some((r.trim().parse().ok()?, 1)),
|
||||
}
|
||||
};
|
||||
let (start, old_count) = range(old)?;
|
||||
let (_, new_count) = range(new)?;
|
||||
Some((start, old_count, new_count))
|
||||
}
|
||||
|
||||
/// The two paths of `diff --git a/x b/y` (ambiguous with spaces: equal halves win).
|
||||
fn split_git_paths(rest: &str) -> (Option<String>, Option<String>) {
|
||||
let rest = rest.trim();
|
||||
if let Some((a, b)) = rest.strip_prefix('"').and_then(|r| r.split_once("\" ")) {
|
||||
return (Some(strip_prefix(&unquote(&format!("\"{a}\"")), "a/")), Some(strip_prefix(&unquote(b), "b/")));
|
||||
}
|
||||
let splits: Vec<usize> = rest.match_indices(" b/").map(|(i, _)| i).collect();
|
||||
let pick = splits.iter().find(|&&i| rest[..i].strip_prefix("a/") == Some(&rest[i + 3..])).or(splits.first());
|
||||
match pick {
|
||||
Some(&i) => (Some(strip_prefix(&rest[..i], "a/")), Some(rest[i + 3..].to_owned())),
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
|
||||
/// A `---` / `+++` path: `None` for `/dev/null`; timestamps and the `a/` / `b/` prefix removed.
|
||||
fn diff_path(raw: &str, prefix: &str) -> Option<String> {
|
||||
let raw = raw.split('\t').next().unwrap_or(raw).trim_end();
|
||||
let path = unquote(raw);
|
||||
if path == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
Some(strip_prefix(&path, prefix))
|
||||
}
|
||||
|
||||
fn rename_path(raw: &str) -> Result<String, String> {
|
||||
let path = unquote(raw.trim());
|
||||
if path.is_empty() { Err("Could not determine file path from git diff".into()) } else { Ok(path) }
|
||||
}
|
||||
|
||||
fn strip_prefix(path: &str, prefix: &str) -> String {
|
||||
path.strip_prefix(prefix).unwrap_or(path).to_owned()
|
||||
}
|
||||
|
||||
/// Git quotes paths with special characters: `"a/x \"y\""`.
|
||||
fn unquote(s: &str) -> String {
|
||||
let Some(inner) = s.strip_prefix('"').and_then(|s| s.strip_suffix('"')) else { return s.to_owned() };
|
||||
let mut out = String::new();
|
||||
let mut chars = inner.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != '\\' {
|
||||
out.push(c);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('n') => out.push('\n'),
|
||||
Some('t') => out.push('\t'),
|
||||
Some(c) => out.push(c),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn finish_section(s: Section) -> Result<FileOp, String> {
|
||||
const NO_PATH: &str = "Could not determine file path from git diff";
|
||||
let n = s.line;
|
||||
let old = match &s.old {
|
||||
Some(p) => p.clone(),
|
||||
None => s.rename_from.clone().or_else(|| s.git_old.clone()),
|
||||
};
|
||||
let new = match &s.new {
|
||||
Some(p) => p.clone(),
|
||||
None => s.rename_to.clone().or_else(|| s.git_new.clone()),
|
||||
};
|
||||
if s.copy_from.is_some() || s.copy_to.is_some() {
|
||||
// Git writes a copy only with `-C`; JetBrains reads it as a change of the source.
|
||||
let to = s.copy_to.clone().or(new).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
|
||||
let from = s.copy_from.clone().or(old).unwrap_or_default();
|
||||
let note = format!(
|
||||
"the copy of {from} to {to} was skipped: copies are not supported; add the new file with `*** Add File` \
|
||||
instead"
|
||||
);
|
||||
return Ok(FileOp::Skip { path: to, note });
|
||||
}
|
||||
if s.new_file || (s.old.as_ref().is_some_and(|o| o.is_none())) {
|
||||
let path = new.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
|
||||
ensure_safe_path(&path, "Add File")?;
|
||||
let mut lines = Vec::new();
|
||||
let mut no_newline = false;
|
||||
for h in &s.hunks {
|
||||
for l in &h.lines {
|
||||
match l {
|
||||
Line::Add(t) => lines.push(t.as_str()),
|
||||
_ => return Err(format!("`{path}` is a new file, but its hunk has context or removed lines")),
|
||||
}
|
||||
}
|
||||
no_newline |= h.new_no_newline;
|
||||
}
|
||||
let mut text = lines.join("\n");
|
||||
if !lines.is_empty() && !no_newline {
|
||||
text.push('\n');
|
||||
}
|
||||
return Ok(FileOp::Add { path, text });
|
||||
}
|
||||
if s.deleted || (s.new.as_ref().is_some_and(|n| n.is_none())) {
|
||||
let path = old.ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
|
||||
ensure_safe_path(&path, "Delete File")?;
|
||||
return Ok(FileOp::Delete { path });
|
||||
}
|
||||
// Only git's rename headers say a file moves: plain diffs often name the sides
|
||||
// differently (`--- a.txt.orig` / `+++ a.txt`, `diff -ru old/ new/`).
|
||||
if s.rename_from.is_some() || s.rename_to.is_some() {
|
||||
let path = old.or(new.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
|
||||
ensure_safe_path(&path, "Update File")?;
|
||||
let move_to = new.filter(|p| *p != path);
|
||||
if let Some(m) = &move_to {
|
||||
ensure_safe_path(m, "Move to")?;
|
||||
}
|
||||
if s.hunks.is_empty() && move_to.is_none() && !s.mode_change {
|
||||
return Err(format!("line {n}: `{path}` has no changes"));
|
||||
}
|
||||
return Ok(FileOp::Update { path, move_to, hunks: s.hunks, or: Vec::new() });
|
||||
}
|
||||
let path = new.clone().or(old.clone()).ok_or_else(|| format!("{NO_PATH} (line {n})"))?;
|
||||
ensure_safe_path(&path, "Update File")?;
|
||||
if s.hunks.is_empty() {
|
||||
// Only the file mode changes: nothing to do to the text, as in JetBrains.
|
||||
if s.mode_change {
|
||||
return Ok(FileOp::Update { path, move_to: None, hunks: Vec::new(), or: Vec::new() });
|
||||
}
|
||||
return Err(format!("line {n}: `{path}` has no changes"));
|
||||
}
|
||||
let below_first_dir = |p: &Option<String>| p.as_ref().and_then(|p| p.split_once('/')).map(|(_, r)| r.to_owned());
|
||||
let mut or: Vec<String> = Vec::new();
|
||||
let others =
|
||||
if old == new { [None, None, None] } else { [old.clone(), below_first_dir(&new), below_first_dir(&old)] };
|
||||
for p in others.into_iter().flatten() {
|
||||
if !p.is_empty() && p != path && !or.contains(&p) {
|
||||
or.push(p);
|
||||
}
|
||||
}
|
||||
Ok(FileOp::Update { path, move_to: None, hunks: s.hunks, or })
|
||||
}
|
||||
|
||||
// --- applying -------------------------------------------------------------------
|
||||
|
||||
/// How exactly a hunk's lines matched the file, tried in this order.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum Fuzz {
|
||||
Exact,
|
||||
TrailingWhitespace,
|
||||
Whitespace,
|
||||
/// Whitespace, and typographic dashes, quotes and spaces as their ASCII forms.
|
||||
Punctuation,
|
||||
}
|
||||
|
||||
impl Fuzz {
|
||||
fn same(self, a: &str, b: &str) -> bool {
|
||||
match self {
|
||||
Fuzz::Exact => a == b,
|
||||
Fuzz::TrailingWhitespace => a.trim_end() == b.trim_end(),
|
||||
Fuzz::Whitespace => a.trim() == b.trim(),
|
||||
Fuzz::Punctuation => a.trim().chars().map(normalize_char).eq(b.trim().chars().map(normalize_char)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn describe(self) -> &'static str {
|
||||
match self {
|
||||
Fuzz::Exact => "exactly",
|
||||
Fuzz::TrailingWhitespace => "ignoring trailing whitespace",
|
||||
Fuzz::Whitespace => "ignoring indentation and whitespace",
|
||||
Fuzz::Punctuation => "ignoring whitespace and typographic punctuation (dashes, quotes, spaces)",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_char(c: char) -> char {
|
||||
match c {
|
||||
'\u{2010}'..='\u{2015}' | '\u{2212}' => '-',
|
||||
'\u{2018}'..='\u{201B}' => '\'',
|
||||
'\u{201C}'..='\u{201F}' => '"',
|
||||
'\u{00A0}' | '\u{2002}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => ' ',
|
||||
c => c,
|
||||
}
|
||||
}
|
||||
|
||||
const LEVELS: [Fuzz; 4] = [Fuzz::Exact, Fuzz::TrailingWhitespace, Fuzz::Whitespace, Fuzz::Punctuation];
|
||||
|
||||
/// The result of [`apply`]: the new text and the hunks that matched only loosely.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub struct Applied {
|
||||
pub text: String,
|
||||
/// (1-based hunk, how it matched) for hunks that did not match exactly.
|
||||
pub fuzzy: Vec<(usize, Fuzz)>,
|
||||
}
|
||||
|
||||
/// Applies `hunks` in order to `text` (`\n` line endings). A hunk is searched
|
||||
/// after the previous one, then (unless it ends the file) from the top. Errors
|
||||
/// name the hunk and the lines that were not found.
|
||||
pub fn apply(text: &str, hunks: &[Hunk]) -> Result<Applied, String> {
|
||||
let trailing_newline = text.ends_with('\n');
|
||||
let mut lines: Vec<String> = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
let mut l: Vec<String> = text.split('\n').map(str::to_owned).collect();
|
||||
if trailing_newline {
|
||||
l.pop();
|
||||
}
|
||||
l
|
||||
};
|
||||
let original_len = lines.len();
|
||||
let mut cursor = 0usize;
|
||||
let mut fuzzy = Vec::new();
|
||||
let mut ends_without_newline = None;
|
||||
for (index, hunk) in hunks.iter().enumerate() {
|
||||
let n = index + 1;
|
||||
let file: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
let mut anchor_line = None;
|
||||
for anchor in &hunk.anchors {
|
||||
let find =
|
||||
|from: usize| LEVELS.iter().find_map(|level| (from..file.len()).find(|&i| level.same(file[i], anchor)));
|
||||
let found = find(cursor).or_else(|| find(0)).ok_or_else(|| {
|
||||
format!(
|
||||
"Hunk context not found: {}: cannot find the line `{anchor}` its `@@` header names",
|
||||
hunk.label(n)
|
||||
)
|
||||
})?;
|
||||
anchor_line = Some(found);
|
||||
cursor = found + 1;
|
||||
}
|
||||
// Line numbers of a diff count the original file: shift them by what earlier hunks added.
|
||||
let shift = |s: usize| (s as isize + lines.len() as isize - original_len as isize).max(0) as usize;
|
||||
let mut old = hunk.old_lines();
|
||||
if old.is_empty() {
|
||||
let at = if !hunk.anchors.is_empty() {
|
||||
cursor
|
||||
} else if hunk.at_eof {
|
||||
file.len()
|
||||
} else {
|
||||
// `@@ -5,0 +6,2 @@` inserts after line 5; without a position, at the end.
|
||||
hunk.old_start.map_or(file.len(), |s| shift(s).min(file.len()))
|
||||
};
|
||||
let new: Vec<String> = hunk.new_lines().into_iter().map(str::to_owned).collect();
|
||||
cursor = at + new.len();
|
||||
lines.splice(at..at, new);
|
||||
continue;
|
||||
}
|
||||
let hint = hunk.old_start.map(|s| shift(s.saturating_sub(1)));
|
||||
// Models often repeat the `@@` line as the hunk's first context line: then the
|
||||
// block starts at the anchor rather than after it.
|
||||
let find = |old: &[&str]| {
|
||||
find_block(&file, old, cursor, hint, hunk.at_eof)
|
||||
.or_else(|| anchor_line.and_then(|a| find_block(&file, old, a, hint, hunk.at_eof)))
|
||||
.or_else(|| (cursor > 0 && !hunk.at_eof).then(|| find_block(&file, old, 0, hint, false)).flatten())
|
||||
};
|
||||
let mut found = find(&old);
|
||||
// A blank last line in the patch that the file does not have (Codex does the same).
|
||||
if found.is_none() && old.last() == Some(&"") {
|
||||
old.pop();
|
||||
if !old.is_empty() {
|
||||
found = find(&old);
|
||||
}
|
||||
}
|
||||
let Some((at, level)) = found else {
|
||||
return Err(mismatch(&file, &old, hint, &hunk.label(n), hunk.at_eof));
|
||||
};
|
||||
if level != Fuzz::Exact {
|
||||
fuzzy.push((n, level));
|
||||
}
|
||||
if at + old.len() == file.len() {
|
||||
if hunk.new_no_newline {
|
||||
ends_without_newline = Some(true);
|
||||
} else if hunk.old_no_newline {
|
||||
ends_without_newline = Some(false);
|
||||
}
|
||||
}
|
||||
// Context lines keep the file's spelling (a loose match must not re-indent them).
|
||||
let mut new = Vec::new();
|
||||
let mut matched = 0usize;
|
||||
for line in &hunk.lines {
|
||||
match line {
|
||||
Line::Context(_) | Line::Remove(_) if matched >= old.len() => {}
|
||||
Line::Context(_) => {
|
||||
new.push(file[at + matched].to_owned());
|
||||
matched += 1;
|
||||
}
|
||||
Line::Remove(_) => matched += 1,
|
||||
Line::Add(s) => new.push(s.clone()),
|
||||
}
|
||||
}
|
||||
let removed = old.len();
|
||||
cursor = at + new.len();
|
||||
lines.splice(at..at + removed, new);
|
||||
}
|
||||
let mut result = lines.join("\n");
|
||||
let newline = match ends_without_newline {
|
||||
Some(no) => !no,
|
||||
None => trailing_newline || text.is_empty(),
|
||||
};
|
||||
if newline && !lines.is_empty() {
|
||||
result.push('\n');
|
||||
}
|
||||
Ok(Applied { text: result, fuzzy })
|
||||
}
|
||||
|
||||
/// Where `old` occurs at or after `from`, as exactly as possible; with a hint,
|
||||
/// the occurrence nearest to it. A hunk that ends the file must match its end.
|
||||
fn find_block(lines: &[&str], old: &[&str], from: usize, hint: Option<usize>, at_eof: bool) -> Option<(usize, Fuzz)> {
|
||||
if old.len() > lines.len() {
|
||||
return None;
|
||||
}
|
||||
let last = lines.len() - old.len();
|
||||
let matches = |at: usize, level: Fuzz| old.iter().zip(&lines[at..]).all(|(o, l)| level.same(l, o));
|
||||
for level in LEVELS {
|
||||
if at_eof {
|
||||
if last >= from && matches(last, level) {
|
||||
return Some((last, level));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut best: Option<usize> = None;
|
||||
for at in from..=last {
|
||||
if !matches(at, level) {
|
||||
continue;
|
||||
}
|
||||
match hint {
|
||||
None => return Some((at, level)),
|
||||
Some(h) => {
|
||||
if best.is_none_or(|b| at.abs_diff(h) < b.abs_diff(h)) {
|
||||
best = Some(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(at) = best {
|
||||
return Some((at, level));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The error for lines that were not found, with the closest partial match.
|
||||
fn mismatch(lines: &[&str], old: &[&str], hint: Option<usize>, label: &str, at_eof: bool) -> String {
|
||||
let mut msg = format!("Hunk context not found: {label} does not apply: these lines were not found:\n");
|
||||
for l in old {
|
||||
let _ = writeln!(msg, " {l}");
|
||||
}
|
||||
// The start with the longest run of matching lines (ignoring whitespace).
|
||||
let mut best: Option<(usize, usize)> = None;
|
||||
for at in 0..lines.len() {
|
||||
let run = old.iter().zip(&lines[at..]).take_while(|(o, l)| Fuzz::Punctuation.same(o, l)).count();
|
||||
let better = match best {
|
||||
None => run > 0,
|
||||
Some((b, r)) => run > r || (run == r && hint.is_some_and(|h| at.abs_diff(h) < b.abs_diff(h))),
|
||||
};
|
||||
if better {
|
||||
best = Some((at, run));
|
||||
}
|
||||
}
|
||||
match best {
|
||||
Some((at, run)) if run < old.len() => {
|
||||
let actual = lines.get(at + run).map_or("(end of file)".to_owned(), |l| format!("`{l}`"));
|
||||
let _ = write!(
|
||||
msg,
|
||||
"Closest match at line {}: line {} is {actual}, but the patch expects `{}`.",
|
||||
at + 1,
|
||||
at + run + 1,
|
||||
old[run]
|
||||
);
|
||||
}
|
||||
Some((at, _)) if at_eof => {
|
||||
let _ = write!(
|
||||
msg,
|
||||
"They occur at line {}, but the hunk is marked `*** End of File` and they do not end the file.",
|
||||
at + 1
|
||||
);
|
||||
}
|
||||
Some((at, _)) => {
|
||||
let _ = write!(msg, "They occur at line {}.", at + 1);
|
||||
}
|
||||
None => {
|
||||
// A common mistake: only part of a line after `-` / ` `.
|
||||
let partial = old
|
||||
.iter()
|
||||
.filter(|o| !o.trim().is_empty())
|
||||
.find_map(|o| lines.iter().position(|l| l.contains(o.trim())).map(|i| (o.trim(), i)));
|
||||
match partial {
|
||||
Some((o, i)) => {
|
||||
let _ = write!(
|
||||
msg,
|
||||
"Line {} contains `{o}` but is `{}`: patch lines must be whole lines, with their indentation.",
|
||||
i + 1,
|
||||
lines[i]
|
||||
);
|
||||
}
|
||||
None => msg.push_str("No line of them is in the file; read the file again before patching it."),
|
||||
}
|
||||
}
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -1,584 +0,0 @@
|
||||
//! Parser and engine tests, including JetBrains' `PatchApplyEngineTest` cases.
|
||||
|
||||
use super::*;
|
||||
|
||||
fn update(ops: &[FileOp]) -> &[Hunk] {
|
||||
match &ops[0] {
|
||||
FileOp::Update { hunks, .. } => hunks,
|
||||
other => panic!("not an update: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_patch(text: &str, patch: &str) -> Result<String, String> {
|
||||
let ops = parse(patch)?;
|
||||
apply(text, update(&ops)).map(|a| a.text)
|
||||
}
|
||||
|
||||
fn patch(lines: &[&str]) -> String {
|
||||
lines.iter().map(|l| format!("{l}\n")).collect()
|
||||
}
|
||||
|
||||
fn hunk(lines: &[(char, &str)], at_eof: bool) -> Hunk {
|
||||
let lines = lines
|
||||
.iter()
|
||||
.map(|&(p, t)| match p {
|
||||
'-' => Line::Remove(t.into()),
|
||||
'+' => Line::Add(t.into()),
|
||||
_ => Line::Context(t.into()),
|
||||
})
|
||||
.collect();
|
||||
Hunk { lines, at_eof, ..Hunk::default() }
|
||||
}
|
||||
|
||||
// --- Codex format -------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn parses_every_codex_operation() {
|
||||
let ops = parse(
|
||||
"*** Begin Patch\n*** Add File: new.txt\n+one\n+two\n*** Delete File: old.txt\n*** Update File: a.rs\n\
|
||||
*** Move to: b.rs\n@@ fn main\n ctx\n-old\n+new\n*** End Patch\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ops,
|
||||
vec![
|
||||
FileOp::Add { path: "new.txt".into(), text: "one\ntwo\n".into() },
|
||||
FileOp::Delete { path: "old.txt".into() },
|
||||
FileOp::Update {
|
||||
path: "a.rs".into(),
|
||||
move_to: Some("b.rs".into()),
|
||||
hunks: vec![Hunk {
|
||||
anchors: vec!["fn main".into()],
|
||||
lines: vec![Line::Context("ctx".into()), Line::Remove("old".into()), Line::Add("new".into())],
|
||||
..Hunk::default()
|
||||
}],
|
||||
or: vec![],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_tolerates_wrappers_crlf_and_blank_separators() {
|
||||
let ops = parse(
|
||||
"apply_patch <<'EOF'\r\n*** Begin Patch\r\n*** Add File: x\r\n+a\r\n+\r\n\r\n*** Update File: y\r\n-a\r\n+b\r\n\r\n*** End Patch\r\nEOF",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(ops[0], FileOp::Add { path: "x".into(), text: "a\n\n".into() });
|
||||
assert_eq!(update(&ops[1..])[0].lines, vec![Line::Remove("a".into()), Line::Add("b".into())]);
|
||||
// JetBrains: a heredoc around the patch.
|
||||
let ops = parse(&patch(&["<<EOF", "*** Begin Patch", "*** Add File: notes.txt", "+alpha", "*** End Patch", "EOF"]))
|
||||
.unwrap();
|
||||
assert_eq!(ops, vec![FileOp::Add { path: "notes.txt".into(), text: "alpha\n".into() }]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_empty_add_and_move_only() {
|
||||
let ops = parse("*** Begin Patch\n*** Add File: e\n*** Update File: a\n*** Move to: b\n*** End Patch").unwrap();
|
||||
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
|
||||
assert_eq!(ops[1], FileOp::Update { path: "a".into(), move_to: Some("b".into()), hunks: vec![], or: vec![] });
|
||||
}
|
||||
|
||||
/// JetBrains `PatchApplyEngineTest`: the parse errors.
|
||||
#[test]
|
||||
fn codex_errors() {
|
||||
let fails = |lines: &[&str], expected: &str| {
|
||||
let e = parse(&patch(lines)).unwrap_err();
|
||||
assert!(e.contains(expected), "expected `{expected}` in: {e}");
|
||||
};
|
||||
fails(&["oops"], "patch must include *** Begin Patch");
|
||||
fails(&["*** Begin Patch", "*** Add File: notes.txt", "+alpha"], "patch must include *** End Patch");
|
||||
fails(&["*** Begin Patch", "*** End Patch"], "patch did not contain any operations");
|
||||
fails(&["*** Begin Patch", "oops", "*** End Patch"], "Unexpected patch line: oops");
|
||||
fails(&["*** Begin Patch", "*** Add File: ", "*** End Patch"], "Add File requires a path");
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Add File: notes.txt", "alpha", "*** End Patch"],
|
||||
"Add File lines must start with +",
|
||||
);
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Add File: notes.txt", "+alpha", "beta", "*** End Patch"],
|
||||
"Add File lines must start with + (line 4: `beta`)",
|
||||
);
|
||||
fails(&["*** Begin Patch", "*** Delete File: ", "*** End Patch"], "Delete File requires a path");
|
||||
fails(&["*** Begin Patch", "*** Update File: ", "*** End Patch"], "Update File requires a path");
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: ", "*** End Patch"],
|
||||
"Move to requires a path",
|
||||
);
|
||||
fails(&["*** Begin Patch", "*** Update File: s.txt", "*** End Patch"], "Update File requires at least one hunk");
|
||||
fails(&["*** Begin Patch", "*** Update File: s.txt", "oops", "*** End Patch"], "Expected @@ hunk header");
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Update File: s.txt", " alpha", "-beta", "+gamma", "oops", "*** End Patch"],
|
||||
"Expected @@ hunk header",
|
||||
);
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Update File: s.txt", "@@ def sample()", "oops", "*** End Patch"],
|
||||
"Hunk lines must start with space, +, or -",
|
||||
);
|
||||
fails(&["*** Begin Patch", "*** Update File: s.txt", "@@", "*** End Patch"], "Empty hunk in Update File");
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Update File: s.txt", "@@", "## Goals", "*** End Patch"],
|
||||
"Strict @@ pair hunk requires second @@ delimiter",
|
||||
);
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Delete File: bad\\npath.txt", "*** End Patch"],
|
||||
"Delete File path contains control",
|
||||
);
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Add File: bad\\npath.txt", "+alpha", "*** End Patch"],
|
||||
"Add File path contains control characters or escape sequences",
|
||||
);
|
||||
fails(&["*** Begin Patch", "*** Update File: bad\\npath.txt", "*** End Patch"], "Update File path contains");
|
||||
fails(
|
||||
&["*** Begin Patch", "*** Update File: s.txt", "*** Move to: bad\\npath.txt", "*** End Patch"],
|
||||
"Move to path contains",
|
||||
);
|
||||
fails(&["*** Begin Patch", "*** Update File: a", "-x", "*** End of File", "-y", "*** End Patch"], "Expected @@");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_hunks_split_on_markers_and_stack_anchors() {
|
||||
let ops = parse(
|
||||
"*** Begin Patch\n*** Update File: a\n@@ class A\n@@ def f():\n-x\n+y\n@@\n-p\n+q\n*** End of File\n*** End Patch",
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = update(&ops);
|
||||
assert_eq!(hunks.len(), 2);
|
||||
assert_eq!(hunks[0].anchors, vec!["class A".to_owned(), "def f():".to_owned()]);
|
||||
assert!(!hunks[0].at_eof && hunks[1].at_eof);
|
||||
assert!(hunks[1].anchors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_headers_with_line_numbers_are_hints_not_anchors() {
|
||||
for header in ["@@@ -48,6 +48,7 @@@", "@@ -48,6 +48,7 @@", "@@ -48,6 +48,7 @@ fn sample()"] {
|
||||
let ops = parse(&patch(&["*** Begin Patch", "*** Update File: s", header, " a", "-b", "+c", "*** End Patch"]))
|
||||
.unwrap();
|
||||
let h = &update(&ops)[0];
|
||||
assert!(h.anchors.is_empty(), "{header}: {:?}", h.anchors);
|
||||
assert_eq!(h.old_start, Some(48), "{header}");
|
||||
}
|
||||
let ops =
|
||||
parse(&patch(&["*** Begin Patch", "*** Update File: s", "@@ def sample()", " a", "*** End Patch"])).unwrap();
|
||||
assert_eq!(update(&ops)[0].anchors, vec!["def sample()".to_owned()]);
|
||||
// JetBrains `applyHunks works with unified-diff style hunk headers`.
|
||||
let p = patch(&[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: s",
|
||||
"@@ -1,3 +1,4 @@",
|
||||
" alpha",
|
||||
"-beta",
|
||||
"+BETA",
|
||||
" gamma",
|
||||
"+delta",
|
||||
"*** End Patch",
|
||||
]);
|
||||
assert_eq!(apply_patch("alpha\nbeta\ngamma\n", &p).unwrap(), "alpha\nBETA\ngamma\ndelta\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_pair_hunks() {
|
||||
let p = patch(&[
|
||||
"*** Begin Patch",
|
||||
"*** Update File: s",
|
||||
"@@",
|
||||
"## Goals",
|
||||
"- Keep old wording",
|
||||
"@@",
|
||||
"## Goals",
|
||||
"- Keep new wording",
|
||||
"*** End Patch",
|
||||
]);
|
||||
let ops = parse(&p).unwrap();
|
||||
let h = &update(&ops)[0];
|
||||
assert!(h.anchors.is_empty());
|
||||
assert_eq!(
|
||||
h.lines,
|
||||
vec![
|
||||
Line::Remove("## Goals".into()),
|
||||
Line::Remove("- Keep old wording".into()),
|
||||
Line::Add("## Goals".into()),
|
||||
Line::Add("- Keep new wording".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(apply_patch("# T\n## Goals\n- Keep old wording\n", &p).unwrap(), "# T\n## Goals\n- Keep new wording\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_codex_hunks_with_anchors() {
|
||||
let text = "class A:\n def f():\n return 1\nclass B:\n def f():\n return 1\n";
|
||||
let patch = "*** Begin Patch\n*** Update File: a.py\n@@ class B:\n def f():\n- return 1\n+ return 2\n*** End Patch";
|
||||
assert_eq!(
|
||||
apply_patch(text, patch).unwrap(),
|
||||
"class A:\n def f():\n return 1\nclass B:\n def f():\n return 2\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_several_hunks_in_order() {
|
||||
let text = "a\nb\nc\nd\ne\nf\n";
|
||||
let patch = "*** Begin Patch\n*** Update File: x\n@@\n a\n-b\n+B\n@@\n e\n-f\n+F\n+G\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, patch).unwrap(), "a\nB\nc\nd\ne\nF\nG\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_lines_twice_match_in_order() {
|
||||
let text = "x\ny\nx\ny\n";
|
||||
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-x\n+1\n@@\n-x\n+2\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, patch).unwrap(), "1\ny\n2\ny\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_of_file_hunks_must_end_the_file() {
|
||||
let text = "end\nmid\nend\n";
|
||||
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-end\n+END\n*** End of File\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, patch).unwrap(), "end\nmid\nEND\n");
|
||||
let beta = hunk(&[('-', "beta"), ('+', "BETA")], true);
|
||||
assert_eq!(apply("alpha\nbeta\n", std::slice::from_ref(&beta)).unwrap().text, "alpha\nBETA\n");
|
||||
let e = apply("alpha\nbeta\ngamma\n", &[beta]).unwrap_err();
|
||||
assert!(e.starts_with("Hunk context not found") && e.contains("do not end the file"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hunks_out_of_order_are_found_from_the_top() {
|
||||
let hunks = [hunk(&[('-', "one"), ('+', "ONE")], true), hunk(&[('-', "two"), ('+', "TWO")], false)];
|
||||
assert_eq!(apply("one\ntwo\nthree\none\n", &hunks).unwrap().text, "one\nTWO\nthree\nONE\n");
|
||||
let text = "fn a() {\n one();\n two();\n}\n";
|
||||
let p =
|
||||
"*** Begin Patch\n*** Update File: f\n@@\n- two();\n+ 2();\n@@\n- one();\n+ 1();\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, p).unwrap(), "fn a() {\n 1();\n 2();\n}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_repeated_hunks_apply_deterministically() {
|
||||
let hunks: Vec<Hunk> = (0..80).map(|i| hunk(&[('-', "alpha"), ('+', &format!("alpha-{i}"))], false)).collect();
|
||||
let text = "alpha\n".repeat(600);
|
||||
let expected: String = (0..80).map(|i| format!("alpha-{i}\n")).collect::<String>() + &"alpha\n".repeat(520);
|
||||
assert_eq!(apply(&text, &hunks).unwrap().text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_the_anchor_repeated_as_context() {
|
||||
let text = "package greet\n\nfunc prefix() string {\n\treturn \"Merhaba, \"\n}\n";
|
||||
let patch = "*** Begin Patch\n*** Update File: f\n@@ func prefix() string {\n func prefix() string {\n-\treturn \"Merhaba, \"\n+\treturn \"Selam, \"\n }\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, patch).unwrap(), "package greet\n\nfunc prefix() string {\n\treturn \"Selam, \"\n}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_additions_go_after_the_anchor_or_at_the_end() {
|
||||
let text = "fn a() {}\nfn b() {}\n";
|
||||
let after = "*** Begin Patch\n*** Update File: f\n@@ fn a() {}\n+fn a2() {}\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, after).unwrap(), "fn a() {}\nfn a2() {}\nfn b() {}\n");
|
||||
let end = "*** Begin Patch\n*** Update File: f\n@@\n+fn c() {}\n*** End Patch";
|
||||
assert_eq!(apply_patch(text, end).unwrap(), "fn a() {}\nfn b() {}\nfn c() {}\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fuzz_levels_are_tried_in_order_and_reported() {
|
||||
let text = "fn main() { \n let x = 1;\n}\n";
|
||||
let ops =
|
||||
parse("*** Begin Patch\n*** Update File: f\n@@\n fn main() {\n- let x = 1;\n+ let x = 2;\n*** End Patch")
|
||||
.unwrap();
|
||||
let a = apply(text, update(&ops)).unwrap();
|
||||
assert_eq!(a.text, "fn main() { \n let x = 2;\n}\n");
|
||||
assert_eq!(a.fuzzy, vec![(1, Fuzz::TrailingWhitespace)]);
|
||||
let ops = parse("*** Begin Patch\n*** Update File: f\n@@\n-let x = 1;\n+ let y = 1;\n*** End Patch").unwrap();
|
||||
let a = apply(text, update(&ops)).unwrap();
|
||||
assert_eq!(a.text, "fn main() { \n let y = 1;\n}\n");
|
||||
assert_eq!(a.fuzzy, vec![(1, Fuzz::Whitespace)]);
|
||||
|
||||
// JetBrains: typographic punctuation, exact matches first.
|
||||
let typographic = "import asyncio # local import \u{2013} avoids top\u{2011}level dep";
|
||||
let ascii = "import asyncio # local import - avoids top-level dep";
|
||||
let a =
|
||||
apply(&format!("{typographic}\n"), &[hunk(&[('-', ascii), ('+', "import asyncio # HELLO")], false)]).unwrap();
|
||||
assert_eq!(a.text, "import asyncio # HELLO\n");
|
||||
assert_eq!(a.fuzzy, vec![(1, Fuzz::Punctuation)]);
|
||||
let both = [hunk(&[('-', ascii), ('+', "# FIRST")], false), hunk(&[('-', ascii), ('+', "# SECOND")], false)];
|
||||
let text = format!("first\n{typographic}\n{typographic}\n");
|
||||
assert_eq!(apply(&text, &both).unwrap().text, "first\n# FIRST\n# SECOND\n");
|
||||
let text = format!("first\n{typographic}\n{ascii}\n");
|
||||
let exact = apply(&text, &[hunk(&[('-', ascii), ('+', "# EXACT")], false)]).unwrap();
|
||||
assert_eq!(exact.text, format!("first\n{typographic}\n# EXACT\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_blank_context_line_is_optional() {
|
||||
let text = "a\nb";
|
||||
let patch = "*** Begin Patch\n*** Update File: f\n@@\n-b\n+c\n \n*** End Patch";
|
||||
assert_eq!(apply_patch(text, patch).unwrap(), "a\nc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_a_missing_final_newline_and_handles_empty_files() {
|
||||
assert_eq!(apply_patch("a\nb", "*** Begin Patch\n*** Update File: f\n-b\n+c\n*** End Patch").unwrap(), "a\nc");
|
||||
assert_eq!(apply_patch("", "*** Begin Patch\n*** Update File: f\n+x\n*** End Patch").unwrap(), "x\n");
|
||||
assert_eq!(apply_patch("a\n", "*** Begin Patch\n*** Update File: f\n-a\n*** End Patch").unwrap(), "");
|
||||
let h = hunk(&[('-', "alpha"), ('+', "alpha2"), (' ', "beta")], false);
|
||||
assert_eq!(apply("alpha \nbeta\n", &[h]).unwrap().text, "alpha2\nbeta\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reports_where_a_hunk_went_wrong() {
|
||||
let text = "fn a() {\n one();\n two();\n}\n";
|
||||
let patch =
|
||||
"*** Begin Patch\n*** Update File: f\n@@\n fn a() {\n one();\n- three();\n+ four();\n*** End Patch";
|
||||
let e = apply_patch(text, patch).unwrap_err();
|
||||
assert!(e.starts_with("Hunk context not found: hunk 1 does not apply"), "{e}");
|
||||
assert!(e.contains(" fn a() {\n one();\n three();\n"), "{e}");
|
||||
assert!(e.contains("Closest match at line 1: line 3 is ` two();`, but the patch expects ` three();`"), "{e}");
|
||||
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n@@ fn zzz\n-x\n*** End Patch").unwrap_err();
|
||||
assert!(e.contains("hunk 1 (@@ fn zzz)") && e.contains("`fn zzz`"), "{e}");
|
||||
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-nothing\n*** End Patch").unwrap_err();
|
||||
assert!(e.contains("No line of them"), "{e}");
|
||||
let e = apply_patch(text, "*** Begin Patch\n*** Update File: f\n-two\n+three\n*** End Patch").unwrap_err();
|
||||
assert!(e.contains("Line 3 contains `two` but is ` two();`: patch lines must be whole lines"), "{e}");
|
||||
}
|
||||
|
||||
// --- unified diffs ------------------------------------------------------
|
||||
|
||||
const GIT: &str = "diff --git a/src/lib.rs b/src/lib.rs
|
||||
index 3b18e51..a9e0c2f 100644
|
||||
--- a/src/lib.rs
|
||||
+++ b/src/lib.rs
|
||||
@@ -1,3 +1,3 @@ mod x;
|
||||
fn a() {}
|
||||
-fn b() {}
|
||||
+fn b() { todo!() }
|
||||
fn c() {}
|
||||
@@ -10,2 +10,3 @@
|
||||
fn j() {}
|
||||
+fn k() {}
|
||||
fn l() {}
|
||||
diff --git a/new.txt b/new.txt
|
||||
new file mode 100644
|
||||
index 0000000..e69de29
|
||||
--- /dev/null
|
||||
+++ b/new.txt
|
||||
@@ -0,0 +1,2 @@
|
||||
+hello
|
||||
+world
|
||||
diff --git a/gone.txt b/gone.txt
|
||||
deleted file mode 100644
|
||||
--- a/gone.txt
|
||||
+++ /dev/null
|
||||
@@ -1 +0,0 @@
|
||||
-bye
|
||||
diff --git a/old name.txt b/new name.txt
|
||||
similarity index 90%
|
||||
rename from old name.txt
|
||||
rename to new name.txt
|
||||
--- a/old name.txt
|
||||
+++ b/new name.txt
|
||||
@@ -1 +1 @@
|
||||
-x
|
||||
+y
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn parses_git_diffs() {
|
||||
let ops = parse(GIT).unwrap();
|
||||
assert_eq!(ops.len(), 4);
|
||||
match &ops[0] {
|
||||
FileOp::Update { path, move_to: None, hunks, or } => {
|
||||
assert_eq!(path, "src/lib.rs");
|
||||
assert!(or.is_empty(), "{or:?}");
|
||||
assert_eq!(hunks.len(), 2);
|
||||
assert_eq!(hunks[0].old_start, Some(1));
|
||||
assert_eq!(hunks[1].lines.len(), 3);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(ops[1], FileOp::Add { path: "new.txt".into(), text: "hello\nworld\n".into() });
|
||||
assert_eq!(ops[2], FileOp::Delete { path: "gone.txt".into() });
|
||||
assert!(matches!(&ops[3], FileOp::Update { path, move_to: Some(to), .. }
|
||||
if path == "old name.txt" && to == "new name.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_git_diffs_inside_begin_patch() {
|
||||
let ops = parse(&patch(&[
|
||||
"*** Begin Patch",
|
||||
"diff --git a/s.txt b/s.txt",
|
||||
"--- a/s.txt",
|
||||
"+++ b/s.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-old",
|
||||
"+new",
|
||||
"*** End Patch",
|
||||
]))
|
||||
.unwrap();
|
||||
assert!(
|
||||
matches!(&ops[..], [FileOp::Update { path, move_to: None, hunks, .. }] if path == "s.txt" && hunks.len() == 1)
|
||||
);
|
||||
// `--- x` removing a `-- x` line keeps a Codex patch a Codex patch.
|
||||
let ops =
|
||||
parse("*** Begin Patch\n*** Update File: q.sql\n@@\n--- old comment\n+-- new comment\n*** End Patch").unwrap();
|
||||
assert_eq!(update(&ops)[0].lines, vec![Line::Remove("-- old comment".into()), Line::Add("-- new comment".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_plain_unified_diffs_and_pure_renames() {
|
||||
let ops =
|
||||
parse("--- a.txt\t2024-01-01 10:00:00\n+++ a.txt\t2024-01-02 10:00:00\n@@ -1,2 +1,2 @@\n-a\n+b\n c\n").unwrap();
|
||||
assert!(
|
||||
matches!(&ops[0], FileOp::Update { path, move_to: None, hunks, .. } if path == "a.txt" && hunks.len() == 1)
|
||||
);
|
||||
// Differently named sides are not a move: the `+++` path, else the others.
|
||||
let ops = parse("--- a.txt.orig\n+++ a.txt\n@@ -1 +1 @@\n-a\n+b\n").unwrap();
|
||||
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
|
||||
if path == "a.txt" && or == &["a.txt.orig"]));
|
||||
let ops = parse(
|
||||
"diff -ru old/src/a.txt new/src/a.txt\n--- old/src/a.txt\t2024-01-01\n+++ new/src/a.txt\t2024-01-02\n@@ -1 +1 @@\n-a\n+b\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(&ops[0], FileOp::Update { path, move_to: None, or, .. }
|
||||
if path == "new/src/a.txt" && or == &["old/src/a.txt", "src/a.txt"]));
|
||||
let ops = parse("diff --git a/x.rs b/y/x.rs\nsimilarity index 100%\nrename from x.rs\nrename to y/x.rs\n").unwrap();
|
||||
assert_eq!(
|
||||
ops[0],
|
||||
FileOp::Update { path: "x.rs".into(), move_to: Some("y/x.rs".into()), hunks: vec![], or: vec![] }
|
||||
);
|
||||
let ops = parse("diff --git a/e b/e\nnew file mode 100644\nindex 0000000..e69de29\n").unwrap();
|
||||
assert_eq!(ops[0], FileOp::Add { path: "e".into(), text: String::new() });
|
||||
let ops = parse("diff --git \"a/sp ace\" \"b/sp ace\"\ndeleted file mode 100644\n").unwrap();
|
||||
assert_eq!(ops[0], FileOp::Delete { path: "sp ace".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_counts_are_tolerated() {
|
||||
// Counts too large: the next header ends the hunk.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -1,9 +1,9 @@\n-a\n+b\n--- a/g\n+++ b/g\n@@ -1 +1 @@\n-c\n+d\n").unwrap();
|
||||
assert_eq!(ops.len(), 2);
|
||||
assert_eq!(update(&ops)[0].lines.len(), 2);
|
||||
// Counts too small: following hunk lines still belong to it.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -1,1 +1,1 @@\n-a\n+b\n c\n").unwrap();
|
||||
assert_eq!(update(&ops)[0].lines.len(), 3);
|
||||
// A bare `@@` reads to the next header; empty lines are empty context.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@\n x\n\n-y\n+z\n\n").unwrap();
|
||||
assert_eq!(
|
||||
update(&ops)[0].lines,
|
||||
vec![Line::Context("x".into()), Line::Context(String::new()), Line::Remove("y".into()), Line::Add("z".into())]
|
||||
);
|
||||
// `git format-patch` signature.
|
||||
let ops = parse("Subject: x\n---\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n-- \n2.43.0\n").unwrap();
|
||||
assert_eq!(ops.len(), 1);
|
||||
// `*** End of File` inside a unified hunk.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@\n-a\n+b\n*** End of File\n").unwrap();
|
||||
assert!(update(&ops)[0].at_eof);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unified_errors() {
|
||||
assert!(parse("@@ -1 +1 @@\n-a\n+b\n").unwrap_err().contains("patch must include *** Begin Patch"));
|
||||
assert!(parse("--- a/f\n+++ b/f\n@@ -x +y @@\n-a\n").unwrap_err().contains("cannot read"));
|
||||
let e = parse("diff --git a/f b/f\nBinary files a/f and b/f differ\n").unwrap_err();
|
||||
assert!(e.contains("Binary git patch is not supported"), "{e}");
|
||||
assert!(parse("diff --git a/f b/f\nindex 1..2\n").unwrap_err().contains("no changes"));
|
||||
assert!(parse("--- /dev/null\n+++ b/n\n@@ -1 +1 @@\n x\n").unwrap_err().contains("new file"));
|
||||
let e = parse("--- a/f\n+++ b/f\n@@ -1 +1 @@\nzzz\n").unwrap_err();
|
||||
assert!(e.contains("Hunk lines must start with space, +, or - (line 4: `zzz`)"), "{e}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_unified_hunks_near_their_line_numbers() {
|
||||
let text = "x\nsame\nx\nx\nx\nsame\nx\n";
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -6,1 +6,1 @@\n-same\n+changed\n").unwrap();
|
||||
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
|
||||
// Wrong line numbers still apply where the context is.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -40,2 +40,2 @@\n x\n-same\n+changed\n").unwrap();
|
||||
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nx\nx\nx\nchanged\nx\n");
|
||||
// Insertion after line 2 (`-2,0`).
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -2,0 +3,1 @@\n+new\n").unwrap();
|
||||
assert_eq!(apply(text, update(&ops)).unwrap().text, "x\nsame\nnew\nx\nx\nx\nsame\nx\n");
|
||||
// Later line numbers shift by what earlier hunks added.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -0,0 +1,2 @@\n+n1\n+n2\n@@ -6,1 +8,1 @@\n-same\n+changed\n").unwrap();
|
||||
assert_eq!(apply(text, update(&ops)).unwrap().text, "n1\nn2\nx\nsame\nx\nx\nx\nchanged\nx\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn applies_the_git_example() {
|
||||
let text = "fn a() {}\nfn b() {}\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn l() {}\n";
|
||||
let ops = parse(GIT).unwrap();
|
||||
let out = apply(text, update(&ops)).unwrap().text;
|
||||
assert_eq!(
|
||||
out,
|
||||
"fn a() {}\nfn b() { todo!() }\nfn c() {}\nfn d() {}\nfn e() {}\nfn f() {}\nfn g() {}\nfn h() {}\nfn i() {}\nfn j() {}\nfn k() {}\nfn l() {}\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_newline_markers() {
|
||||
let add = parse("--- /dev/null\n+++ b/n\n@@ -0,0 +1 @@\n+x\n\\ No newline at end of file\n").unwrap();
|
||||
assert_eq!(add[0], FileOp::Add { path: "n".into(), text: "x".into() });
|
||||
// Adding the final newline.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n\\ No newline at end of file\n+b\n").unwrap();
|
||||
assert_eq!(apply("a\nb", update(&ops)).unwrap().text, "a\nb\n");
|
||||
// Removing it.
|
||||
let ops = parse("--- a/f\n+++ b/f\n@@ -1,2 +1,2 @@\n a\n-b\n+b\n\\ No newline at end of file\n").unwrap();
|
||||
assert_eq!(apply("a\nb\n", update(&ops)).unwrap().text, "a\nb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_git_paths() {
|
||||
assert_eq!(split_git_paths("a/x y b/x y"), (Some("x y".into()), Some("x y".into())));
|
||||
assert_eq!(split_git_paths("a/x b/y"), (Some("x".into()), Some("y".into())));
|
||||
assert_eq!(parse_range_header("@@ -3 +4,0 @@"), Some((3, 1, 0)));
|
||||
assert_eq!(parse_range_header("@@ -1,2 +1,3 @@ fn x"), Some((1, 2, 3)));
|
||||
assert_eq!(parse_range_header("@@@ -48,6 +48,7 @@@"), Some((48, 6, 7)));
|
||||
assert_eq!(parse_range_header("@@ fn x"), None);
|
||||
assert_eq!(unquote("\"a\\\"b\\\\c\""), "a\"b\\c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_changes_and_copies_do_not_fail_the_patch() {
|
||||
// A mode-only section is an update without hunks; the hunks of the others still apply.
|
||||
let diff = patch(&[
|
||||
"diff --git a/run.sh b/run.sh",
|
||||
"old mode 100644",
|
||||
"new mode 100755",
|
||||
"diff --git a/a.txt b/a.txt",
|
||||
"--- a/a.txt",
|
||||
"+++ b/a.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-x",
|
||||
"+y",
|
||||
]);
|
||||
let ops = parse(&diff).unwrap();
|
||||
assert_eq!(ops.len(), 2);
|
||||
assert_eq!(ops[0], FileOp::Update { path: "run.sh".into(), move_to: None, hunks: Vec::new(), or: Vec::new() });
|
||||
assert_eq!(update(&ops[1..]).len(), 1);
|
||||
|
||||
// A mode change together with a rename is a move.
|
||||
let ops = parse("diff --git a/x b/y\nold mode 100644\nnew mode 100755\nrename from x\nrename to y\n").unwrap();
|
||||
assert!(matches!(&ops[0], FileOp::Update { move_to: Some(m), .. } if m == "y"));
|
||||
|
||||
// A copy is skipped with a note.
|
||||
let diff = patch(&[
|
||||
"diff --git a/src/a.rs b/src/b.rs",
|
||||
"similarity index 90%",
|
||||
"copy from src/a.rs",
|
||||
"copy to src/b.rs",
|
||||
"--- a/src/a.rs",
|
||||
"+++ b/src/b.rs",
|
||||
"@@ -1 +1 @@",
|
||||
"-a",
|
||||
"+b",
|
||||
"diff --git a/c.txt b/c.txt",
|
||||
"--- a/c.txt",
|
||||
"+++ b/c.txt",
|
||||
"@@ -1 +1 @@",
|
||||
"-1",
|
||||
"+2",
|
||||
]);
|
||||
let ops = parse(&diff).unwrap();
|
||||
assert_eq!(ops.len(), 2);
|
||||
match &ops[0] {
|
||||
FileOp::Skip { path, note } => {
|
||||
assert_eq!(path, "src/b.rs");
|
||||
assert!(note.contains("copy of src/a.rs to src/b.rs") && note.contains("not supported"), "{note}");
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
assert_eq!(ops[1].path(), "c.txt");
|
||||
}
|
||||
@@ -1,950 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/RefactoringToolset.kt, renameTarget.kt), Apache-2.0.
|
||||
//! `rename_refactoring` with the language server's rename. The target is given
|
||||
//! as in JetBrains: a `contextSnippet`, else `line` + `column`, else the one
|
||||
//! declaration of `symbolName` in the file; `targetIndex` picks from the
|
||||
//! candidates an earlier call listed. Failures are results (`ok: false` with an
|
||||
//! `error.kind`), not tool errors, so the agent can correct the call.
|
||||
//!
|
||||
//! Symbols are told apart by their definition (go to definition): the language
|
||||
//! server has no PSI elements to compare. It renames code references only;
|
||||
//! `searchInComments` / `searchInText` add whole-word occurrences found with
|
||||
//! Find in Files.
|
||||
|
||||
use super::normalize_newlines;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::ops::search::SearchEvent;
|
||||
use ide_core::{DocId, FileEdit, IdeError, IdeResult, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::GotoKind;
|
||||
use ide_search::{SearchContext, SearchQuery, SearchScope};
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Occurrences of the name whose definition is looked up when no position is given.
|
||||
const MAX_LOOKUPS: usize = 50;
|
||||
|
||||
/// Files `searchInText` treats as plain text.
|
||||
const TEXT_EXTENSIONS: &[&str] = &["txt", "text", "md", "markdown", "rst", "adoc", "asciidoc", "properties"];
|
||||
|
||||
pub(super) fn rename_refactoring() -> Tool {
|
||||
Tool::new(
|
||||
"rename_refactoring",
|
||||
"Rename a symbol and every reference to it. Set preview=true to analyze the rename and write nothing.
|
||||
The language server resolves the symbol, so it also updates a reference that a text search cannot
|
||||
find. Always prefer this tool over a text replacement.
|
||||
|
||||
Point at the symbol with ONE of these, in order of preference: contextSnippet, then line and column.
|
||||
With neither of them, the file must declare one symbol with this name. symbolName is always required.
|
||||
A position that resolves to another name refuses the rename.
|
||||
|
||||
targetIndex is not a third way to point at the symbol. It picks from a candidates list an earlier call
|
||||
returned, so repeat that call with every argument it had, and add targetIndex.
|
||||
|
||||
Read `applied`. It is the only success signal. Nothing is written in part, except after
|
||||
error.kind=write_failed, and its hint says what was written.
|
||||
ok=false with candidates asks you to pick one with targetIndex. ok=false with conflicts asks for another
|
||||
newName. ok=false with error tells you in `hint` what to correct.
|
||||
|
||||
applyAutomaticRenamers has no effect here: the language server renames the symbol and its references. A library or \
|
||||
compiled symbol cannot be renamed.
|
||||
|
||||
By default, the rename changes no comment, no string literal and no plain-text occurrence of the name.
|
||||
Set searchInComments or searchInText to change them too.",
|
||||
|call| async move { run(&call).await },
|
||||
)
|
||||
.write()
|
||||
.param(Param::required("pathInProject", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::required("symbolName", ParamType::String, "Exact, case-sensitive name of the symbol to rename."))
|
||||
.param(Param::required(
|
||||
"newName",
|
||||
ParamType::String,
|
||||
"New name for the symbol. It must be a valid identifier for the symbol's language.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"contextSnippet",
|
||||
ParamType::String,
|
||||
"Optional. Exact fragment of the file that shows the symbol. It must match the file once, and contain \
|
||||
symbolName.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"line",
|
||||
ParamType::Integer,
|
||||
"Optional. 1-based line of the symbol. Copy it from search_symbol output. Needs column too.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"column",
|
||||
ParamType::Integer,
|
||||
"Optional. 1-based column of the symbol. Copy it from search_symbol output. Needs line too.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"targetIndex",
|
||||
ParamType::Integer,
|
||||
"Optional. 1-based pick from the candidates list an earlier call returned. Repeat every other argument of \
|
||||
that call.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"preview",
|
||||
ParamType::Boolean,
|
||||
"Optional. Analyze only: report affects and conflicts, and write nothing. The default is false.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"applyAutomaticRenamers",
|
||||
ParamType::Boolean,
|
||||
"Optional. Also rename what the IDE renames along with the symbol. The default is false.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"searchInComments",
|
||||
ParamType::Boolean,
|
||||
"Optional. Also rename the name in comments and string literals. The default is false.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"searchInText",
|
||||
ParamType::Boolean,
|
||||
"Optional. Also rename the name in plain-text occurrences, such as non-code files. The default is false.",
|
||||
))
|
||||
}
|
||||
|
||||
// --- the result ------------------------------------------------------------------
|
||||
|
||||
#[derive(Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RenameResult {
|
||||
ok: bool,
|
||||
applied: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
resolved_symbol: Option<SymbolInfo>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
affects: Option<Affects>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
renamed_file: Option<RenamedFile>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
candidates: Option<Vec<Candidate>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
conflicts: Option<Vec<Conflict>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<RenameError>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
note: Option<String>,
|
||||
}
|
||||
|
||||
impl RenameResult {
|
||||
fn failed(kind: &'static str, hint: impl Into<String>) -> Self {
|
||||
Self { error: Some(RenameError { kind, hint: hint.into() }), ..Self::default() }
|
||||
}
|
||||
|
||||
fn with_symbol(mut self, symbol: Option<SymbolInfo>) -> Self {
|
||||
self.resolved_symbol = symbol;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SymbolInfo {
|
||||
name: String,
|
||||
declaration_text: String,
|
||||
declaration_file: String,
|
||||
declaration_line: u32,
|
||||
language: String,
|
||||
}
|
||||
|
||||
/// The blast radius: files with an edit, and the edits.
|
||||
#[derive(Serialize)]
|
||||
struct Affects {
|
||||
files: usize,
|
||||
usages: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RenamedFile {
|
||||
previous_path: String,
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Candidate {
|
||||
target_index: usize,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
kind: Option<String>,
|
||||
symbol: SymbolInfo,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Conflict {
|
||||
description: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct RenameError {
|
||||
kind: &'static str,
|
||||
hint: String,
|
||||
}
|
||||
|
||||
// --- the call --------------------------------------------------------------------
|
||||
|
||||
struct Request {
|
||||
name: String,
|
||||
new_name: String,
|
||||
snippet: Option<String>,
|
||||
position: Option<(i64, i64)>,
|
||||
target_index: Option<i64>,
|
||||
preview: bool,
|
||||
automatic_renamers: bool,
|
||||
in_comments: bool,
|
||||
in_text: bool,
|
||||
}
|
||||
|
||||
async fn run(call: &Call) -> IdeResult<Output> {
|
||||
let raw = call.str("pathInProject")?;
|
||||
let name = call.str("symbolName")?;
|
||||
let new_name = call.str("newName")?;
|
||||
for (value, arg) in [(&raw, "pathInProject"), (&name, "symbolName"), (&new_name, "newName")] {
|
||||
if value.trim().is_empty() {
|
||||
return Err(IdeError::invalid(format!("{arg} is empty")));
|
||||
}
|
||||
}
|
||||
let (line, column) = (call.opt_i64("line")?, call.opt_i64("column")?);
|
||||
if line.is_some() != column.is_some() {
|
||||
return Err(IdeError::invalid("line and column must be given together"));
|
||||
}
|
||||
let request = Request {
|
||||
name,
|
||||
new_name,
|
||||
snippet: call.opt_str("contextSnippet")?,
|
||||
position: line.zip(column),
|
||||
target_index: call.opt_i64("targetIndex")?,
|
||||
preview: call.flag("preview", false)?,
|
||||
automatic_renamers: call.flag("applyAutomaticRenamers", false)?,
|
||||
in_comments: call.flag("searchInComments", false)?,
|
||||
in_text: call.flag("searchInText", false)?,
|
||||
};
|
||||
let path = call.resolve_inside(&raw)?;
|
||||
if !path.is_file() {
|
||||
return Err(IdeError::not_found(format!("File not found: {raw}")));
|
||||
}
|
||||
let host = call.host();
|
||||
let project = call.project().clone();
|
||||
let (p, target) = (project.clone(), path.clone());
|
||||
let (doc, transient) = host
|
||||
.blocking(move || {
|
||||
// The rename reads and changes files across the project: no buffer may be older than its file.
|
||||
let open: Vec<PathBuf> = p
|
||||
.documents
|
||||
.open_documents()
|
||||
.into_iter()
|
||||
.filter_map(|d| p.documents.snapshot(d).ok().map(|i| i.path.to_path_buf()))
|
||||
.chain(std::iter::once(target.clone()))
|
||||
.collect();
|
||||
super::refresh_from_disk(&p, &open);
|
||||
super::open_document(&p, &target)
|
||||
})
|
||||
.await?;
|
||||
// Closed on every path out, a panic or a dropped call included.
|
||||
let _close = CloseDocument(project.clone(), transient.then_some(doc));
|
||||
Output::json(rename(call, doc, &path, &request).await?)
|
||||
}
|
||||
|
||||
/// Closes a transient document when dropped.
|
||||
struct CloseDocument(Arc<crate::Project>, Option<DocId>);
|
||||
|
||||
impl Drop for CloseDocument {
|
||||
fn drop(&mut self) {
|
||||
if let Some(doc) = self.1 {
|
||||
self.0.documents.close(doc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn rename(call: &Call, doc: DocId, path: &Path, req: &Request) -> IdeResult<RenameResult> {
|
||||
let snap = call.project().documents.text_snapshot(doc)?;
|
||||
let file = File { call, doc, path, text: &snap.text, language: snap.language.0.clone() };
|
||||
let pos = match resolve_target(&file, req).await? {
|
||||
Resolution::Resolved(pos) => pos,
|
||||
Resolution::Unresolved(kind, hint) => return Ok(RenameResult::failed(kind, hint)),
|
||||
Resolution::Ambiguous(positions) => {
|
||||
let mut candidates = Vec::new();
|
||||
for (i, pos) in positions.iter().enumerate() {
|
||||
let (symbol, kind) = file.symbol_at(&req.name, *pos).await;
|
||||
candidates.push(Candidate { target_index: i + 1, kind, symbol });
|
||||
}
|
||||
let hint = format!(
|
||||
"'{}' matches {} symbols here. Nothing changed. Call again with every argument of this call plus \
|
||||
targetIndex, or narrow the call with a contextSnippet that shows the one you mean.",
|
||||
req.name,
|
||||
positions.len()
|
||||
);
|
||||
return Ok(RenameResult { candidates: Some(candidates), ..RenameResult::failed("ambiguous_symbol", hint) });
|
||||
}
|
||||
};
|
||||
let symbol = Some(file.symbol_at(&req.name, pos).await.0);
|
||||
if req.name == req.new_name {
|
||||
let hint = format!("The symbol already has the name '{}'.", req.new_name);
|
||||
return Ok(RenameResult::failed("new_name_matches_current", hint).with_symbol(symbol));
|
||||
}
|
||||
if !plausible_identifier(&req.new_name) {
|
||||
let hint = format!("'{}' is not a valid identifier for {}.", req.new_name, file.language);
|
||||
return Ok(RenameResult::failed("new_name_invalid", hint).with_symbol(symbol));
|
||||
}
|
||||
let host = call.host();
|
||||
let window = call.window();
|
||||
match crate::ops::lsp::lsp_prepare_rename(&host, window, doc, pos).await {
|
||||
Ok(Some(_)) => {}
|
||||
Ok(None) => {
|
||||
let hint = "This symbol cannot be renamed. A library or compiled symbol has to change upstream.";
|
||||
return Ok(RenameResult::failed("not_renamable", hint).with_symbol(symbol));
|
||||
}
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
|
||||
Err(e) => return Ok(RenameResult::failed("language_not_supported", no_server_hint(&e)).with_symbol(symbol)),
|
||||
}
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let renamed = tokio::select! {
|
||||
r = crate::ops::lsp::lsp_rename(&host, window, request, doc, pos, req.new_name.clone()) => r,
|
||||
_ = call.cancel.cancelled() => {
|
||||
host.cancel_request(request);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
};
|
||||
let renamed = match renamed {
|
||||
Ok(r) => r,
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => return Err(e),
|
||||
// Servers explain a clash with an existing name in the error (gopls: "would conflict with").
|
||||
Err(e) if e.message.to_lowercase().contains("conflict") => {
|
||||
let hint = "A conflict refused the rename and nothing changed. Choose another newName.";
|
||||
let conflicts = Some(vec![Conflict { description: e.message }]);
|
||||
return Ok(RenameResult { conflicts, ..RenameResult::failed("conflicts_found", hint) }.with_symbol(symbol));
|
||||
}
|
||||
Err(e) => return Ok(RenameResult::failed("new_name_refused", e.message).with_symbol(symbol)),
|
||||
};
|
||||
let mut edit = renamed.edit;
|
||||
if edit.files.is_empty() {
|
||||
return Ok(RenameResult::failed("unknown", "The language server found nothing to rename.").with_symbol(symbol));
|
||||
}
|
||||
if req.in_comments || req.in_text {
|
||||
add_text_occurrences(call, &mut edit, req).await?;
|
||||
}
|
||||
for f in &edit.files {
|
||||
let paths: Vec<&Path> = match f {
|
||||
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
|
||||
FileEdit::Rename { from, to } => vec![from, to],
|
||||
};
|
||||
for p in paths {
|
||||
if let Err(e) = super::super::tool::ensure_inside(call.root(), p) {
|
||||
let hint = format!(
|
||||
"{}. Nothing was renamed. A rename that must change a file outside the project cannot run here.",
|
||||
e.message
|
||||
);
|
||||
return Ok(RenameResult::failed("read_only_usages", hint).with_symbol(symbol));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut files: Vec<&Path> = Vec::new();
|
||||
let mut usages = 0;
|
||||
for f in &edit.files {
|
||||
if let FileEdit::Edit { path, edits, .. } = f {
|
||||
usages += edits.len();
|
||||
if !files.contains(&&**path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
let affects = Some(Affects { files: files.len(), usages });
|
||||
let mut notes = Vec::new();
|
||||
if renamed.incomplete.is_some() {
|
||||
notes.push("The language server reported that the references may be incomplete.");
|
||||
}
|
||||
if req.automatic_renamers {
|
||||
notes.push(
|
||||
"applyAutomaticRenamers had no effect: the language server renames the symbol and its references only.",
|
||||
);
|
||||
}
|
||||
let note = (!notes.is_empty()).then(|| notes.join(" "));
|
||||
if req.preview {
|
||||
return Ok(RenameResult { ok: true, affects, resolved_symbol: symbol, note, ..RenameResult::default() });
|
||||
}
|
||||
// The IDE's configuration among the files: the user agrees first.
|
||||
let mut checked: Vec<&Path> = Vec::new();
|
||||
for f in &edit.files {
|
||||
let paths: Vec<&Path> = match f {
|
||||
FileEdit::Edit { path, .. } | FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => vec![path],
|
||||
FileEdit::Rename { from, to } => vec![from, to],
|
||||
};
|
||||
for p in paths {
|
||||
if !checked.contains(&p) {
|
||||
checked.push(p);
|
||||
call.check_writable(p).await?;
|
||||
}
|
||||
}
|
||||
}
|
||||
let renamed_file = edit.files.iter().find_map(|f| match f {
|
||||
FileEdit::Rename { from, to } if super::same_file(from, path) => {
|
||||
Some(RenamedFile { previous_path: call.display(from), path: call.display(to) })
|
||||
}
|
||||
_ => None,
|
||||
});
|
||||
let (h, w) = (host.clone(), window.to_owned());
|
||||
let written = crate::ops::lsp::apply_workspace_edit(&h, &w, edit).await;
|
||||
if let Err(e) = written {
|
||||
let hint = format!("The rename stopped while it wrote. {} Read the files again before you retry.", e.message);
|
||||
return Ok(RenameResult::failed("write_failed", hint).with_symbol(symbol));
|
||||
}
|
||||
Ok(RenameResult {
|
||||
ok: true,
|
||||
applied: true,
|
||||
resolved_symbol: symbol,
|
||||
affects,
|
||||
renamed_file,
|
||||
note,
|
||||
..RenameResult::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn no_server_hint(e: &IdeError) -> String {
|
||||
format!(
|
||||
"No language server can rename in this file ({}), so nothing was renamed. Without one, change the \
|
||||
occurrences with apply_patch.",
|
||||
e.message
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuses only what no language accepts in a name: whitespace, separators, a leading digit.
|
||||
fn plausible_identifier(name: &str) -> bool {
|
||||
!name.starts_with(|c: char| c.is_ascii_digit())
|
||||
&& !name.chars().any(|c| c.is_whitespace() || c.is_control() || ".,;:()[]{}<>\"'`/\\+*=!?&|^%#~".contains(c))
|
||||
}
|
||||
|
||||
// --- the target ------------------------------------------------------------------
|
||||
|
||||
enum Resolution {
|
||||
Resolved(Pos),
|
||||
/// Several symbols, in document order; `targetIndex` is 1-based.
|
||||
Ambiguous(Vec<Pos>),
|
||||
Unresolved(&'static str, String),
|
||||
}
|
||||
|
||||
/// The file the rename starts in.
|
||||
struct File<'a> {
|
||||
call: &'a Call,
|
||||
doc: DocId,
|
||||
path: &'a Path,
|
||||
text: &'a str,
|
||||
language: String,
|
||||
}
|
||||
|
||||
impl File<'_> {
|
||||
fn line(&self, line: u32) -> &str {
|
||||
self.text.split('\n').nth(line as usize).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Where the symbol at `pos` is defined; `None` without an answer.
|
||||
async fn definition(&self, pos: Pos) -> Option<(PathBuf, Pos)> {
|
||||
let host = self.call.host();
|
||||
let found = crate::ops::lsp::lsp_goto(&host, self.call.window(), self.doc, pos, GotoKind::Definition).await;
|
||||
let loc = found.ok()?.into_iter().find(|l| l.uri.is_none())?;
|
||||
Some((PathBuf::from(&*loc.path), loc.range.start))
|
||||
}
|
||||
|
||||
/// Whether the language server can rename at `pos`; `Err` without one.
|
||||
async fn renameable(&self, pos: Pos) -> Result<bool, Resolution> {
|
||||
let host = self.call.host();
|
||||
match crate::ops::lsp::lsp_prepare_rename(&host, self.call.window(), self.doc, pos).await {
|
||||
Ok(found) => Ok(found.is_some()),
|
||||
Err(e) => Err(Resolution::Unresolved("language_not_supported", no_server_hint(&e))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol (from its definition when known) and its outline kind.
|
||||
async fn symbol_at(&self, name: &str, pos: Pos) -> (SymbolInfo, Option<String>) {
|
||||
let (file, at) = match self.definition(pos).await {
|
||||
Some((file, at)) if super::same_file(&file, self.path) => (self.path.to_path_buf(), at),
|
||||
Some((file, at)) => (file, at),
|
||||
None => (self.path.to_path_buf(), pos),
|
||||
};
|
||||
let declaration_text = if file == self.path {
|
||||
self.line(at.line).trim().to_owned()
|
||||
} else {
|
||||
let project = self.call.project();
|
||||
project
|
||||
.text_of(&file)
|
||||
.ok()
|
||||
.and_then(|t| t.split('\n').nth(at.line as usize).map(|l| l.trim().to_owned()))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let kind = if file == self.path { self.kind_at(at).await } else { None };
|
||||
let symbol = SymbolInfo {
|
||||
name: name.to_owned(),
|
||||
declaration_text,
|
||||
declaration_file: self.call.display(&file),
|
||||
declaration_line: at.line + 1,
|
||||
language: self.language.clone(),
|
||||
};
|
||||
(symbol, kind)
|
||||
}
|
||||
|
||||
/// The outline kind (`field`, `method`) of the declaration whose name is at `pos`.
|
||||
async fn kind_at(&self, pos: Pos) -> Option<String> {
|
||||
let host = self.call.host();
|
||||
let outline = crate::ops::doc::doc_outline(&host, self.call.window(), self.doc).await.ok()?;
|
||||
let mut stack: Vec<&ide_core::OutlineItem> = outline.iter().collect();
|
||||
while let Some(item) = stack.pop() {
|
||||
let r = item.selection_range;
|
||||
if r.start <= pos && pos <= r.end {
|
||||
let kind = format!("{:?}", item.kind);
|
||||
return Some(kind[..1].to_lowercase() + &kind[1..]);
|
||||
}
|
||||
stack.extend(item.children.iter());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Positions of `name` as an identifier (outside comments and strings when a grammar knows them).
|
||||
async fn identifiers(&self, name: &str) -> Vec<Pos> {
|
||||
let host = self.call.host();
|
||||
match crate::ops::syntax::syntax_identifiers(&host, self.call.window(), self.doc, name.to_owned(), None).await {
|
||||
Ok(found) if !found.is_empty() => found.into_iter().map(|o| o.range.start).collect(),
|
||||
_ => word_positions(self.text, name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The symbol the caller pointed at, or why the tool cannot tell which one it is.
|
||||
/// `targetIndex` picks from the list of the branch the other arguments select.
|
||||
async fn resolve_target(file: &File<'_>, req: &Request) -> IdeResult<Resolution> {
|
||||
let resolution = if let Some(snippet) = &req.snippet {
|
||||
by_snippet(file, snippet, &req.name).await
|
||||
} else if let Some((line, column)) = req.position {
|
||||
by_position(file, line, column, &req.name).await
|
||||
} else {
|
||||
by_declaration(file, &req.name).await?
|
||||
};
|
||||
let Some(index) = req.target_index else { return Ok(resolution) };
|
||||
Ok(match resolution {
|
||||
Resolution::Ambiguous(candidates) => pick(&candidates, index),
|
||||
// One symbol now: only a pick of the first agrees with it; the code changed since the list.
|
||||
Resolution::Resolved(pos) if index == 1 => Resolution::Resolved(pos),
|
||||
Resolution::Resolved(pos) => pick(&[pos], index),
|
||||
unresolved => unresolved,
|
||||
})
|
||||
}
|
||||
|
||||
fn pick(candidates: &[Pos], index: i64) -> Resolution {
|
||||
let n = candidates.len();
|
||||
if index < 1 || index as usize > n {
|
||||
return Resolution::Unresolved(
|
||||
"target_index_out_of_range",
|
||||
format!(
|
||||
"targetIndex {index} is outside 1..{n}. This call reports {n} candidate(s). Send the same \
|
||||
pathInProject, symbolName and contextSnippet as the call that listed them."
|
||||
),
|
||||
);
|
||||
}
|
||||
Resolution::Resolved(candidates[index as usize - 1])
|
||||
}
|
||||
|
||||
async fn by_snippet(file: &File<'_>, snippet: &str, name: &str) -> Resolution {
|
||||
if snippet.trim().is_empty() {
|
||||
return Resolution::Unresolved("snippet_not_found", "contextSnippet is blank.".into());
|
||||
}
|
||||
let needle = normalize_newlines(snippet);
|
||||
if !needle.contains(name) {
|
||||
return Resolution::Unresolved(
|
||||
"name_mismatch",
|
||||
format!("contextSnippet does not contain '{name}'. Pass a snippet that shows the symbol."),
|
||||
);
|
||||
}
|
||||
let start = match find_snippet(file.text, &needle) {
|
||||
SnippetMatch::NotFound => {
|
||||
return Resolution::Unresolved(
|
||||
"snippet_not_found",
|
||||
"contextSnippet is not in this file. Read the file again and copy the snippet from its current text."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
SnippetMatch::NotUnique => {
|
||||
return Resolution::Unresolved(
|
||||
"snippet_not_unique",
|
||||
"contextSnippet matches this file more than once. Extend it until it is unique.".into(),
|
||||
);
|
||||
}
|
||||
SnippetMatch::At(start) => start,
|
||||
};
|
||||
let mut symbols: Vec<(Option<(PathBuf, Pos)>, Pos)> = Vec::new();
|
||||
// Why an occurrence could not be asked about; reported only when no other one resolves.
|
||||
let mut refused = None;
|
||||
for pos in snippet_occurrences(file.text, start, needle.len(), name) {
|
||||
match file.renameable(pos).await {
|
||||
Ok(true) => {}
|
||||
Ok(false) => continue,
|
||||
Err(no_server) => {
|
||||
refused.get_or_insert(no_server);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let definition = file.definition(pos).await;
|
||||
if definition.is_none() || !symbols.iter().any(|(d, _)| *d == definition) {
|
||||
symbols.push((definition, pos));
|
||||
}
|
||||
}
|
||||
if let (true, Some(refused)) = (symbols.is_empty(), refused) {
|
||||
return refused;
|
||||
}
|
||||
match symbols.len() {
|
||||
0 => Resolution::Unresolved(
|
||||
"symbol_not_found",
|
||||
format!("'{name}' inside contextSnippet does not resolve to a symbol that can be renamed."),
|
||||
),
|
||||
1 => Resolution::Resolved(symbols[0].1),
|
||||
_ => Resolution::Ambiguous(symbols.into_iter().map(|(_, p)| p).collect()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn by_position(file: &File<'_>, line: i64, column: i64, name: &str) -> Resolution {
|
||||
let line_count = file.text.split('\n').count() as i64;
|
||||
if line < 1 || column < 1 || line > line_count {
|
||||
return Resolution::Unresolved(
|
||||
"position_out_of_bounds",
|
||||
format!("Position {line}:{column} is outside this file. Both numbers are 1-based."),
|
||||
);
|
||||
}
|
||||
let text = file.line(line as u32 - 1);
|
||||
let units: Vec<u16> = text.encode_utf16().collect();
|
||||
// The column has to stay on the named line: past its end it would land further down.
|
||||
if column as usize - 1 > units.len() {
|
||||
return Resolution::Unresolved(
|
||||
"position_out_of_bounds",
|
||||
format!("Column {column} is past the end of line {line}. Both numbers are 1-based."),
|
||||
);
|
||||
}
|
||||
let pos = Pos::new(line as u32 - 1, column as u32 - 1);
|
||||
let word = word_at(&units, pos.col as usize);
|
||||
let in_code = file
|
||||
.identifiers(name)
|
||||
.await
|
||||
.iter()
|
||||
.any(|p| p.line == pos.line && p.col <= pos.col && pos.col <= p.col + units_len(name));
|
||||
match word {
|
||||
Some((start, w)) if w == name && in_code => {
|
||||
let at = Pos::new(pos.line, start as u32);
|
||||
match file.renameable(at).await {
|
||||
Ok(true) => Resolution::Resolved(at),
|
||||
Ok(false) => Resolution::Unresolved(
|
||||
"symbol_not_found",
|
||||
format!("No symbol that can be renamed sits at {line}:{column}."),
|
||||
),
|
||||
Err(no_server) => no_server,
|
||||
}
|
||||
}
|
||||
Some((_, other)) if other != name => Resolution::Unresolved(
|
||||
"name_mismatch",
|
||||
format!("Position {line}:{column} resolves to '{other}', not to '{name}'. Nothing was renamed."),
|
||||
),
|
||||
_ => Resolution::Unresolved(
|
||||
"symbol_not_found",
|
||||
format!("No symbol that can be renamed sits at {line}:{column}."),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// The declarations of `name` in this file: the distinct definitions of its occurrences
|
||||
/// that lie in this file (declared-looking occurrences without go to definition).
|
||||
async fn by_declaration(file: &File<'_>, name: &str) -> IdeResult<Resolution> {
|
||||
let occurrences = file.identifiers(name).await;
|
||||
let mut declarations: Vec<Pos> = Vec::new();
|
||||
let mut answered = false;
|
||||
for pos in occurrences.iter().take(MAX_LOOKUPS) {
|
||||
if file.call.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if let Some((path, at)) = file.definition(*pos).await {
|
||||
answered = true;
|
||||
if super::same_file(&path, file.path) && !declarations.contains(&at) {
|
||||
declarations.push(at);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !answered {
|
||||
declarations = occurrences.into_iter().filter(|p| is_declaration(file.text, *p)).collect();
|
||||
}
|
||||
declarations.sort();
|
||||
Ok(match declarations.len() {
|
||||
0 => Resolution::Unresolved(
|
||||
"symbol_not_found",
|
||||
format!("No symbol named '{name}' is declared in this file. Pass the file that declares it."),
|
||||
),
|
||||
1 => Resolution::Resolved(declarations[0]),
|
||||
_ => Resolution::Ambiguous(declarations),
|
||||
})
|
||||
}
|
||||
|
||||
fn units_len(s: &str) -> u32 {
|
||||
s.encode_utf16().count() as u32
|
||||
}
|
||||
|
||||
fn is_word_unit(u: u16) -> bool {
|
||||
char::from_u32(u as u32).is_some_and(|c| c.is_alphanumeric() || c == '_' || c == '$') || u >= 0x80
|
||||
}
|
||||
|
||||
/// The identifier-like word touching UTF-16 column `col`: (start column, word).
|
||||
fn word_at(units: &[u16], col: usize) -> Option<(usize, String)> {
|
||||
let touches = |i: usize| units.get(i).is_some_and(|&u| is_word_unit(u));
|
||||
let at = if touches(col) {
|
||||
col
|
||||
} else if col > 0 && touches(col - 1) {
|
||||
col - 1
|
||||
} else {
|
||||
return None;
|
||||
};
|
||||
let start = (0..=at).rev().take_while(|&i| touches(i)).last().unwrap_or(at);
|
||||
let end = (at..units.len()).take_while(|&i| touches(i)).last().map_or(at, |e| e + 1);
|
||||
Some((start, String::from_utf16_lossy(&units[start..end])))
|
||||
}
|
||||
|
||||
enum SnippetMatch {
|
||||
NotFound,
|
||||
NotUnique,
|
||||
/// The byte offset of the only occurrence.
|
||||
At(usize),
|
||||
}
|
||||
|
||||
/// Where `needle` occurs in `text`, overlapping occurrences included.
|
||||
fn find_snippet(text: &str, needle: &str) -> SnippetMatch {
|
||||
let Some(start) = text.find(needle) else { return SnippetMatch::NotFound };
|
||||
// One character further, not one byte: the needle may start with a multibyte character.
|
||||
let step = needle.chars().next().map_or(1, char::len_utf8);
|
||||
if text[start + step..].contains(needle) { SnippetMatch::NotUnique } else { SnippetMatch::At(start) }
|
||||
}
|
||||
|
||||
/// Whole-word occurrences of `name` inside the `len` bytes of `text` from byte `start`
|
||||
/// (a snippet's place in the file): the file's text around an occurrence decides whether
|
||||
/// it is a whole word, so a snippet cut in the middle of a word does not make one.
|
||||
fn snippet_occurrences(text: &str, start: usize, len: usize, name: &str) -> Vec<Pos> {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
||||
let mut out = Vec::new();
|
||||
for (at, _) in text[start..start + len].match_indices(name) {
|
||||
let at = start + at;
|
||||
let before = text[..at].chars().next_back();
|
||||
let after = text[at + name.len()..].chars().next();
|
||||
if before.is_some_and(is_word) || after.is_some_and(is_word) {
|
||||
continue;
|
||||
}
|
||||
let line_start = text[..at].rfind('\n').map_or(0, |i| i + 1);
|
||||
out.push(Pos::new(text[..at].matches('\n').count() as u32, units_len(&text[line_start..at])));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Whole-word occurrences of `name` (0-based line, UTF-16 column).
|
||||
fn word_positions(text: &str, name: &str) -> Vec<Pos> {
|
||||
let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '$';
|
||||
let mut out = Vec::new();
|
||||
for (line_no, line) in text.split('\n').enumerate() {
|
||||
for (at, _) in line.match_indices(name) {
|
||||
let before = line[..at].chars().next_back();
|
||||
let after = line[at + name.len()..].chars().next();
|
||||
if before.is_some_and(is_word) || after.is_some_and(is_word) {
|
||||
continue;
|
||||
}
|
||||
out.push(Pos::new(line_no as u32, units_len(&line[..at])));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Words that introduce a declaration of the name after them.
|
||||
const DECLARATION_KEYWORDS: &[&str] = &[
|
||||
"fn",
|
||||
"def",
|
||||
"class",
|
||||
"struct",
|
||||
"enum",
|
||||
"trait",
|
||||
"interface",
|
||||
"type",
|
||||
"func",
|
||||
"function",
|
||||
"fun",
|
||||
"let",
|
||||
"const",
|
||||
"var",
|
||||
"val",
|
||||
"mod",
|
||||
"module",
|
||||
"object",
|
||||
"record",
|
||||
"typealias",
|
||||
"protocol",
|
||||
"union",
|
||||
"namespace",
|
||||
"package",
|
||||
"static",
|
||||
"macro_rules!",
|
||||
];
|
||||
|
||||
/// Whether the occurrence at `p` follows a declaration keyword (`fn name`, `let mut name`).
|
||||
fn is_declaration(text: &str, p: Pos) -> bool {
|
||||
let Some(line) = text.split('\n').nth(p.line as usize) else { return false };
|
||||
let mut units = 0u32;
|
||||
let byte = line
|
||||
.char_indices()
|
||||
.find(|(_, c)| {
|
||||
let here = units >= p.col;
|
||||
units += c.len_utf16() as u32;
|
||||
here
|
||||
})
|
||||
.map_or(line.len(), |(i, _)| i);
|
||||
let mut words = line[..byte].split_whitespace().rev();
|
||||
match words.next() {
|
||||
Some("mut") => words.next() == Some("let"),
|
||||
Some(w) => DECLARATION_KEYWORDS.contains(&w),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
// --- comments and text -------------------------------------------------------------
|
||||
|
||||
/// Adds whole-word occurrences of the old name to `edit`: in comments and string
|
||||
/// literals of the files it changes, and in plain-text files of the project.
|
||||
async fn add_text_occurrences(call: &Call, edit: &mut WorkspaceEdit, req: &Request) -> IdeResult<()> {
|
||||
let edited: Vec<PathBuf> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
FileEdit::Edit { path, .. } => Some(path.to_path_buf()),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
let mut found = Vec::new();
|
||||
if req.in_comments {
|
||||
let scope = || SearchScope::Files { paths: edited.iter().cloned().map(Into::into).collect() };
|
||||
found.extend(occurrences(call, &req.name, scope(), SearchContext::InComments).await?);
|
||||
found.extend(occurrences(call, &req.name, scope(), SearchContext::InStringLiterals).await?);
|
||||
}
|
||||
if req.in_text {
|
||||
let text_file = |p: &Path| {
|
||||
p.extension().and_then(|e| e.to_str()).is_some_and(|e| TEXT_EXTENSIONS.contains(&e.to_lowercase().as_str()))
|
||||
};
|
||||
found.extend(
|
||||
occurrences(call, &req.name, SearchScope::Project, SearchContext::Any)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|(p, _)| text_file(p) && !edited.iter().any(|e| super::same_file(e, p))),
|
||||
);
|
||||
}
|
||||
for (path, range) in found {
|
||||
let text_edit = TextEdit { range, new_text: req.new_name.clone() };
|
||||
let entry = edit.files.iter_mut().find_map(|f| match f {
|
||||
FileEdit::Edit { path: p, edits, .. } if super::same_file(p, &path) => Some(edits),
|
||||
_ => None,
|
||||
});
|
||||
match entry {
|
||||
Some(edits) => {
|
||||
let overlaps =
|
||||
edits.iter().any(|e| e.range.start < range.end && range.start < e.range.end || e.range == range);
|
||||
if !overlaps {
|
||||
edits.push(text_edit);
|
||||
}
|
||||
}
|
||||
None => edit.files.push(FileEdit::Edit { path: path.into(), edits: vec![text_edit], version: None }),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whole-word, case-sensitive Find in Files matches of `name`.
|
||||
async fn occurrences(
|
||||
call: &Call,
|
||||
name: &str,
|
||||
scope: SearchScope,
|
||||
context: SearchContext,
|
||||
) -> IdeResult<Vec<(PathBuf, Range)>> {
|
||||
let query = SearchQuery {
|
||||
pattern: name.to_owned(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: true,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope,
|
||||
max_matches: 0,
|
||||
context,
|
||||
};
|
||||
let host = call.host();
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let files = Arc::new(Mutex::new(Vec::new()));
|
||||
let emit: crate::ops::Emit<SearchEvent> = {
|
||||
let files = files.clone();
|
||||
Arc::new(move |event| {
|
||||
if let SearchEvent::File { file } = event {
|
||||
files.lock().push(file);
|
||||
}
|
||||
})
|
||||
};
|
||||
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
|
||||
tokio::pin!(search);
|
||||
tokio::select! {
|
||||
r = &mut search => r?,
|
||||
_ = call.cancel.cancelled() => {
|
||||
host.cancel_request(request);
|
||||
let _ = search.await;
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
let width = units_len(name);
|
||||
let mut out = Vec::new();
|
||||
for file in std::mem::take(&mut *files.lock()) {
|
||||
for m in &file.matches {
|
||||
for &(from, _) in &m.ranges {
|
||||
let start = Pos::new(m.line, m.text_offset + from);
|
||||
out.push((file.path.to_path_buf(), Range::new(start, Pos::new(m.line, start.col + width))));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_whole_words_in_utf16_columns() {
|
||||
let text = "let foo = foo_bar + foo;\n// ä foo\nfoofoo";
|
||||
assert_eq!(word_positions(text, "foo"), vec![Pos::new(0, 4), Pos::new(0, 20), Pos::new(1, 5)]);
|
||||
let units: Vec<u16> = " int counter;".encode_utf16().collect();
|
||||
assert_eq!(word_at(&units, 6), Some((6, "counter".into())));
|
||||
assert_eq!(word_at(&units, 13), Some((6, "counter".into())));
|
||||
assert_eq!(word_at(&units, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_snippets_starting_with_multibyte_characters() {
|
||||
let text = "let äb = 1;\nlet äb2 = äb;\n";
|
||||
assert!(matches!(find_snippet(text, "äb"), SnippetMatch::NotUnique));
|
||||
assert!(matches!(find_snippet(text, "äb = 1"), SnippetMatch::At(4)));
|
||||
assert!(matches!(find_snippet(text, "ö"), SnippetMatch::NotFound));
|
||||
// Overlapping occurrences are two.
|
||||
assert!(matches!(find_snippet("aaa", "aa"), SnippetMatch::NotUnique));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snippet_occurrences_are_whole_words_of_the_file() {
|
||||
let text = "let beta = 1;\nlet x = alphabeta + beta;\n";
|
||||
// The snippet starts inside `alphabeta`: its `beta` is not a word of the file.
|
||||
let needle = "beta + beta";
|
||||
let SnippetMatch::At(start) = find_snippet(text, needle) else { panic!() };
|
||||
assert_eq!(snippet_occurrences(text, start, needle.len(), "beta"), vec![Pos::new(1, 20)]);
|
||||
let text = "ä beta";
|
||||
assert_eq!(snippet_occurrences(text, 0, text.len(), "beta"), vec![Pos::new(0, 2)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_declarations_and_identifiers() {
|
||||
let text = "use x;\nprint(count)\nlet mut count = 1;\nfn count() {}\n";
|
||||
assert!(!is_declaration(text, Pos::new(1, 6)));
|
||||
assert!(is_declaration(text, Pos::new(2, 8)) && is_declaration(text, Pos::new(3, 3)));
|
||||
assert!(plausible_identifier("hitCount") && plausible_identifier("kebab-case") && plausible_identifier("$x"));
|
||||
assert!(!plausible_identifier("1abc") && !plausible_identifier("bad name") && !plausible_identifier("a.b"));
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
use super::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{Diagnostic, IdeResult, Severity};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![
|
||||
Tool::new(
|
||||
"get_all_open_file_paths",
|
||||
"Returns active editor's and other open editors' file paths relative to the project root.
|
||||
|
||||
Use this tool to explore current open editors.",
|
||||
open_editors,
|
||||
),
|
||||
Tool::new(
|
||||
"get_current_file",
|
||||
"Returns the focused editor's file and selection reported by the IDE.
|
||||
Selection lines and UTF-16 columns are 1-based, end exclusive.
|
||||
Selected text is limited to 65536 UTF-16 units.",
|
||||
current_file,
|
||||
),
|
||||
Tool::new(
|
||||
"get_file_problems",
|
||||
"Returns the problems for a file as reported by the IDE's Problems view, including language servers
|
||||
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
|
||||
Lines and UTF-16 columns are 1-based.",
|
||||
file_problems,
|
||||
)
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::optional(
|
||||
"errorsOnly",
|
||||
ParamType::Boolean,
|
||||
"Whether to include only errors or include both errors and warnings",
|
||||
)),
|
||||
Tool::new(
|
||||
"get_project_problems",
|
||||
"Returns the project's problems as reported by the IDE's Problems view, including language servers
|
||||
and inspections after profile filtering. This reflects what the Problems view shows, rather than a fresh analysis.
|
||||
Lines and UTF-16 columns are 1-based.",
|
||||
project_problems,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
async fn open_editors(call: Call) -> IdeResult<Output> {
|
||||
let windows = call.host.mcp_windows(call.root());
|
||||
let open = call.host.mcp_reports.editors(&windows);
|
||||
let active = call.host.mcp_reports.focused(call.window());
|
||||
Output::json(json!({
|
||||
"activeFilePath": active.map(|e| call.display(&e.path.0)),
|
||||
"openFiles": open.iter().map(|p| call.display(p)).collect::<Vec<_>>(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn current_file(call: Call) -> IdeResult<Output> {
|
||||
let editor = call.host.mcp_reports.focused(call.window());
|
||||
Output::json(match editor {
|
||||
None => json!({}),
|
||||
Some(e) => json!({
|
||||
"filePath": call.display(&e.path.0), "selectedText": e.selected_text,
|
||||
"selection": {
|
||||
"startLine": e.selection.start.line + 1, "startColumn": e.selection.start.col + 1,
|
||||
"endLine": e.selection.end.line + 1, "endColumn": e.selection.end.col + 1,
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
fn problem(d: Diagnostic, text: &str) -> Value {
|
||||
json!({
|
||||
"severity": d.severity, "description": d.message,
|
||||
"lineContent": text.lines().nth(d.range.start.line as usize).unwrap_or_default(),
|
||||
"line": d.range.start.line + 1, "column": d.range.start.col + 1, "source": d.source, "code": d.code,
|
||||
})
|
||||
}
|
||||
|
||||
async fn file_problems(call: Call) -> IdeResult<Output> {
|
||||
let path = call.resolve_readable(&call.str("filePath")?).await?;
|
||||
let errors_only = call.flag("errorsOnly", true)?;
|
||||
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
|
||||
let (project, file) = (call.project.clone(), path.clone());
|
||||
let text = call.host.blocking(move || project.text_of(&file)).await.unwrap_or_default();
|
||||
let errors: Vec<_> = files
|
||||
.into_iter()
|
||||
.filter(|f| f.path.0 == path)
|
||||
.flat_map(|f| f.items)
|
||||
.filter(|d| !errors_only || d.severity == Severity::Error)
|
||||
.map(|d| problem(d, &text))
|
||||
.collect();
|
||||
Output::json(json!({"filePath": call.display(&path), "errors": errors}))
|
||||
}
|
||||
|
||||
async fn project_problems(call: Call) -> IdeResult<Output> {
|
||||
let files = call.host.mcp_reports.problems(&call.host.mcp_windows(call.root()));
|
||||
let files: Vec<_> = files.into_iter().map(|f| json!({
|
||||
"filePath":call.display(&f.path.0), "problems": f.items.into_iter().map(|d| problem(d, "")).collect::<Vec<_>>()
|
||||
})).collect();
|
||||
Output::json(json!({"files":files}))
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ReadToolset.kt,
|
||||
// the list_directory_tree part of FileToolset.kt, src/com/intellij/mcpserver/util/fs.util.kt), Apache-2.0.
|
||||
//! Reading files and listing directories.
|
||||
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{
|
||||
self, RELATIVE_PATH_IN_PROJECT_DESCRIPTION, TIMED_OUT_DESCRIPTION, TIMEOUT_MILLISECONDS_DESCRIPTION,
|
||||
};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_READ_LIMIT: i64 = 2000;
|
||||
const MAX_READ_LIMIT: i64 = 5000;
|
||||
/// Longer lines are cut (minified files would flood the context), in UTF-16 units.
|
||||
const MAX_LINE_LENGTH: usize = 2000;
|
||||
const DEFAULT_TREE_DEPTH: i64 = 5;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![read_file(), list_directory_tree()]
|
||||
}
|
||||
|
||||
// --- read_file -----------------------------------------------------------------
|
||||
|
||||
fn read_file() -> Tool {
|
||||
Tool::new(
|
||||
"read_file",
|
||||
"Reads a file in the project directory or from any project dependency or other project source root.
|
||||
Can read text sources inside jar/zip archives; compiled class files are not decompiled.
|
||||
Returns numbered lines (1-indexed) as text.
|
||||
By default, reads up to 2000 lines starting from the beginning of the file.
|
||||
The maximum accepted limit is 5000 lines.",
|
||||
|call| async move {
|
||||
let offset = positive(&call, "offset", 1)?;
|
||||
let limit = positive(&call, "limit", DEFAULT_READ_LIMIT)?;
|
||||
if limit > MAX_READ_LIMIT {
|
||||
return Err(IdeError::invalid(format!("limit must be <= {MAX_READ_LIMIT}")));
|
||||
}
|
||||
let path = util::resolve_read_file(&call, &call.str("file_path")?).await?;
|
||||
if call.project().documents.by_path(&path).is_none()
|
||||
&& let probe = ide_vfs::probe(&path)?
|
||||
&& probe.binary
|
||||
{
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is a binary file ({} bytes); read_file reads text",
|
||||
call.display(&path),
|
||||
probe.size
|
||||
)));
|
||||
}
|
||||
let text = crate::ops::fs::read_text(&call.host(), call.window(), path).await?;
|
||||
Ok(Output::text(render_slice(&text, offset as usize, limit as usize)?))
|
||||
},
|
||||
)
|
||||
.param(Param::required("file_path", ParamType::String, util::FILE_PATH_DESCRIPTION))
|
||||
.param(Param::optional("offset", ParamType::Integer, "1-based line number to start reading from"))
|
||||
.param(Param::optional(
|
||||
"limit",
|
||||
ParamType::Integer,
|
||||
"Maximum number of lines to return (default: 2000, max: 5000)",
|
||||
))
|
||||
}
|
||||
|
||||
/// An integer argument that must be > 0.
|
||||
fn positive(call: &Call, name: &str, default: i64) -> IdeResult<i64> {
|
||||
match call.opt_i64(name)?.unwrap_or(default) {
|
||||
n if n <= 0 => Err(IdeError::invalid(format!("{name} must be > 0"))),
|
||||
n => Ok(n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Lines `start..` of `text` as `L<n>: <line>`; when more than `max_lines` remain, the first
|
||||
/// and the last halves of the budget with a `…N lines truncated…` marker between them.
|
||||
/// Lines are the editor's: a trailing line break starts an empty last line.
|
||||
fn render_slice(text: &str, start: usize, max_lines: usize) -> IdeResult<String> {
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
let count = lines.len();
|
||||
if start > count {
|
||||
return Err(IdeError::invalid("offset exceeds file length"));
|
||||
}
|
||||
let range = |from: usize, to: usize| {
|
||||
(from..=to).map(|n| format!("L{n}: {}", format_line(lines[n - 1]))).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
let available = count - start + 1;
|
||||
if available <= max_lines {
|
||||
return Ok(range(start, count));
|
||||
}
|
||||
let head = max_lines / 2;
|
||||
let tail = max_lines - head;
|
||||
let head_end = start + head - 1;
|
||||
let tail_start = count - tail + 1;
|
||||
let mut out = String::new();
|
||||
if head > 0 {
|
||||
out.push_str(&range(start, head_end));
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&format!("\u{2026}{} lines truncated\u{2026}", tail_start - head_end - 1));
|
||||
if tail > 0 {
|
||||
out.push('\n');
|
||||
out.push_str(&range(tail_start, count));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `line` cut to [`MAX_LINE_LENGTH`] UTF-16 units, or as many characters when the cut would
|
||||
/// split a surrogate pair (as JetBrains does).
|
||||
fn format_line(line: &str) -> &str {
|
||||
let mut units = 0;
|
||||
for (i, c) in line.char_indices() {
|
||||
if units >= MAX_LINE_LENGTH {
|
||||
return &line[..i];
|
||||
}
|
||||
if units + c.len_utf16() > MAX_LINE_LENGTH {
|
||||
// A pair across the boundary: keep MAX_LINE_LENGTH characters instead.
|
||||
return match line.char_indices().nth(MAX_LINE_LENGTH) {
|
||||
Some((cut, _)) => &line[..cut],
|
||||
None => line,
|
||||
};
|
||||
}
|
||||
units += c.len_utf16();
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
// --- list_directory_tree -------------------------------------------------------
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DirectoryTreeInfo {
|
||||
traversed_directory: String,
|
||||
tree: String,
|
||||
errors: Vec<String>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
listing_timed_out: bool,
|
||||
}
|
||||
|
||||
fn list_directory_tree() -> Tool {
|
||||
Tool::new(
|
||||
"list_directory_tree",
|
||||
"Provides a tree representation of the specified directory in the pseudo graphic format like `tree` utility does.
|
||||
Use this tool to explore the contents of a directory or the whole project.
|
||||
You MUST prefer this tool over listing directories via command line utilities like `ls` or `dir`.",
|
||||
|call| async move {
|
||||
let directory = call.str("directoryPath")?;
|
||||
let max_depth = call.opt_i64("maxDepth")?.unwrap_or(DEFAULT_TREE_DEPTH);
|
||||
let timeout = call.opt_i64("timeout")?.unwrap_or(util::MEDIUM_TIMEOUT.as_millis() as i64);
|
||||
let dir = call.resolve_readable(&directory).await?;
|
||||
if !dir.exists() {
|
||||
return Err(IdeError::not_found(format!("No such directory: {}", dir.display())));
|
||||
}
|
||||
if !dir.is_dir() {
|
||||
return Err(IdeError::invalid(format!("Not a directory: {}", dir.display())));
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_millis(timeout.max(0) as u64);
|
||||
let cancel = call.cancel.clone();
|
||||
let mut tree =
|
||||
TreeRender { out: String::new(), errors: Vec::new(), timed_out: false, deadline, cancel: cancel.clone() };
|
||||
let tree = call
|
||||
.host()
|
||||
.blocking(move || {
|
||||
tree.render(&dir, "", true, max_depth, true);
|
||||
Ok(tree)
|
||||
})
|
||||
.await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Output::json(DirectoryTreeInfo {
|
||||
traversed_directory: directory,
|
||||
tree: tree.out,
|
||||
errors: tree.errors,
|
||||
listing_timed_out: tree.timed_out,
|
||||
})
|
||||
},
|
||||
)
|
||||
.param(Param::required("directoryPath", ParamType::String, RELATIVE_PATH_IN_PROJECT_DESCRIPTION))
|
||||
.param(Param::optional("maxDepth", ParamType::Integer, "Maximum recursion depth"))
|
||||
.param(Param::optional("timeout", ParamType::Integer, TIMEOUT_MILLISECONDS_DESCRIPTION))
|
||||
.output(json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"traversedDirectory": { "type": "string" },
|
||||
"tree": { "type": "string" },
|
||||
"errors": { "type": "array", "items": { "type": "string" } },
|
||||
"listingTimedOut": { "type": "boolean", "description": TIMED_OUT_DESCRIPTION },
|
||||
},
|
||||
"required": ["traversedDirectory", "tree", "errors"],
|
||||
}))
|
||||
}
|
||||
|
||||
/// The `tree`-like listing: the directory's absolute path, then every entry (hidden and
|
||||
/// ignored ones too) by lower-cased name, directories marked with `/`.
|
||||
struct TreeRender {
|
||||
out: String,
|
||||
errors: Vec<String>,
|
||||
timed_out: bool,
|
||||
deadline: Instant,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl TreeRender {
|
||||
fn render(&mut self, path: &Path, indent: &str, is_last: bool, max_depth: i64, is_root: bool) {
|
||||
if max_depth <= 0 || self.timed_out || self.cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= self.deadline {
|
||||
self.timed_out = true;
|
||||
return;
|
||||
}
|
||||
let prefix = match (is_root, is_last) {
|
||||
(true, _) => "",
|
||||
(false, true) => "└── ",
|
||||
(false, false) => "├── ",
|
||||
};
|
||||
let name = if is_root {
|
||||
path.display().to_string()
|
||||
} else {
|
||||
path.file_name().unwrap_or_default().to_string_lossy().into_owned()
|
||||
};
|
||||
let is_dir = path.is_dir() && !path.is_symlink();
|
||||
self.out.push_str(&format!("{indent}{prefix}{name}{}\n", if is_dir { "/" } else { "" }));
|
||||
if !is_dir {
|
||||
return;
|
||||
}
|
||||
let mut children: Vec<_> = match std::fs::read_dir(path) {
|
||||
Ok(entries) => entries.filter_map(|e| e.ok().map(|e| e.path())).collect(),
|
||||
Err(e) => {
|
||||
self.errors.push(format!("Failed to read {}: {e}", path.display()));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let key = |p: &std::path::PathBuf| p.file_name().unwrap_or_default().to_string_lossy().into_owned();
|
||||
children.sort_by_cached_key(|p| (key(p).to_lowercase(), key(p)));
|
||||
let child_indent = format!("{indent}{}", if is_last { " " } else { "│ " });
|
||||
let n = children.len();
|
||||
for (i, child) in children.iter().enumerate() {
|
||||
self.render(child, &child_indent, i + 1 == n, max_depth - 1, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn slice(text: &str, start: usize, max: usize) -> String {
|
||||
render_slice(text, start, max).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn numbers_lines_like_the_editor() {
|
||||
assert_eq!(slice("a\nb", 1, 10), "L1: a\nL2: b");
|
||||
assert_eq!(slice("a\r\nb\n", 1, 10), "L1: a\nL2: b\nL3: ");
|
||||
assert_eq!(slice("", 1, 10), "L1: ");
|
||||
assert_eq!(slice("a\nb\nc", 2, 10), "L2: b\nL3: c");
|
||||
assert!(render_slice("", 2, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
assert!(render_slice("a\nb", 3, 10).unwrap_err().message.contains("offset exceeds file length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_head_and_tail_of_long_slices() {
|
||||
let text = (1..=11).map(|n| format!("line {n}")).collect::<Vec<_>>().join("\n");
|
||||
assert_eq!(slice(&text, 1, 4), "L1: line 1\nL2: line 2\n…7 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 3, 4), "L3: line 3\nL4: line 4\n…5 lines truncated…\nL10: line 10\nL11: line 11");
|
||||
assert_eq!(slice(&text, 1, 1), "…10 lines truncated…\nL11: line 11");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cuts_long_lines_at_utf16_units() {
|
||||
let long = "x".repeat(2100);
|
||||
assert_eq!(format_line(&long).len(), MAX_LINE_LENGTH);
|
||||
// A pair across the boundary keeps 2000 characters.
|
||||
let pair = format!("{}😀{}", "x".repeat(1999), "y".repeat(10));
|
||||
assert_eq!(format_line(&pair).chars().count(), MAX_LINE_LENGTH);
|
||||
assert!(format_line(&pair).ends_with('😀'));
|
||||
let short = format!("{}😀", "x".repeat(1998));
|
||||
assert_eq!(format_line(&short), short);
|
||||
}
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
//! Built-in IDE tools. Transport and discovery live in `ide-mcp`.
|
||||
mod edit;
|
||||
mod editors;
|
||||
mod requests;
|
||||
mod run;
|
||||
mod vcs;
|
||||
pub use requests::Requests;
|
||||
mod files;
|
||||
mod reports;
|
||||
mod search;
|
||||
mod symbols;
|
||||
mod tool;
|
||||
mod util;
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError};
|
||||
use ide_mcp::{ToolCallError, ToolRegistry};
|
||||
pub use reports::{FileProblems, FocusedEditor, Reports};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Registry(Arc<Host>);
|
||||
impl Registry {
|
||||
pub fn new(host: Arc<Host>) -> Self {
|
||||
Self(host)
|
||||
}
|
||||
}
|
||||
|
||||
fn tools() -> Vec<tool::Tool> {
|
||||
let mut tools = files::tools();
|
||||
tools.extend(search::tools());
|
||||
tools.extend(editors::tools());
|
||||
tools.push(symbols::get_symbol_info());
|
||||
tools.push(symbols::find_references());
|
||||
tools.extend(edit::tools());
|
||||
tools.extend(run::tools());
|
||||
tools.extend(vcs::tools());
|
||||
tools
|
||||
}
|
||||
|
||||
// Checked both when listing and calling, so a later trust change takes effect immediately.
|
||||
fn allowed(tool: &ide_mcp::Tool, trusted: bool) -> bool {
|
||||
trusted || tool.read_only
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
fn target(&self, root: &Path) -> Result<(String, Arc<crate::Project>), String> {
|
||||
if self.0.mcp_reports.remote_roots().iter().any(|p| p == root) {
|
||||
return Err("not supported for remote projects yet".into());
|
||||
}
|
||||
let mut windows: Vec<_> = self
|
||||
.0
|
||||
.projects
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.root == root && !p.light)
|
||||
.map(|(w, p)| (w.clone(), p.clone()))
|
||||
.collect();
|
||||
let focused = self.0.mcp_reports.focused_window();
|
||||
windows.sort_by(|a, b| {
|
||||
(Some(&b.0) == focused.as_ref()).cmp(&(Some(&a.0) == focused.as_ref())).then(a.0.cmp(&b.0))
|
||||
});
|
||||
windows.into_iter().next().ok_or_else(|| "project is no longer open".into())
|
||||
}
|
||||
}
|
||||
impl ToolRegistry for Registry {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
self.0
|
||||
.projects()
|
||||
.into_iter()
|
||||
.filter(|p| !p.light)
|
||||
.map(|p| p.root.clone())
|
||||
.chain(self.0.mcp_reports.remote_roots())
|
||||
.collect()
|
||||
}
|
||||
fn tools(&self, root: &Path) -> Result<Vec<ide_mcp::Tool>, String> {
|
||||
let (_, project) = self.target(root)?;
|
||||
let mut all: Vec<_> = tools().into_iter().map(|t| t.metadata).collect();
|
||||
if let Some(plugins) = self.0.plugin_host() {
|
||||
all.extend(plugins.mcp_tools(root).into_iter().map(|(_, _, tool)| tool));
|
||||
}
|
||||
Ok(all.into_iter().filter(|t| allowed(t, project.is_trusted())).collect())
|
||||
}
|
||||
fn call(
|
||||
&self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
) -> Result<String, ToolCallError> {
|
||||
self.call_with_client(root, name, arguments, cancel, "MCP client")
|
||||
}
|
||||
fn call_with_client(
|
||||
&self,
|
||||
root: &Path,
|
||||
name: &str,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let (window, project) = self.target(root).map_err(ToolCallError::Failed)?;
|
||||
if let Some(plugins) = self.0.plugin_host()
|
||||
&& let Some((plugin, tool, metadata)) =
|
||||
plugins.mcp_tools(root).into_iter().find(|(_, _, t)| t.name == name && allowed(t, project.is_trusted()))
|
||||
{
|
||||
return self.call_plugin(window, project, plugins, plugin, tool, metadata, arguments, cancel, client);
|
||||
}
|
||||
let tool = tools()
|
||||
.into_iter()
|
||||
.find(|t| t.metadata.name == name && allowed(&t.metadata, project.is_trusted()))
|
||||
.ok_or_else(|| ToolCallError::UnknownTool(format!("unknown tool: {name}")))?;
|
||||
let args = serde_json::from_str(arguments).map_err(|e| ToolCallError::Failed(e.to_string()))?;
|
||||
let call = tool::Call {
|
||||
host: self.0.clone(),
|
||||
project,
|
||||
window,
|
||||
args,
|
||||
cancel: cancel.clone(),
|
||||
client: client.to_owned(),
|
||||
tool: name.to_owned(),
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ToolCallError::Failed(IdeError::cancelled().message));
|
||||
}
|
||||
let result = self.0.runtime.block_on((tool.handler)(call));
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ToolCallError::Failed(IdeError::cancelled().message));
|
||||
}
|
||||
result.map(|out| out.0).map_err(|e| ToolCallError::Failed(e.message))
|
||||
}
|
||||
}
|
||||
|
||||
impl Host {
|
||||
fn mcp_windows(&self, root: &Path) -> Vec<String> {
|
||||
let mut windows: Vec<_> =
|
||||
self.projects.read().iter().filter(|(_, p)| p.root == root).map(|(w, _)| w.clone()).collect();
|
||||
windows.sort();
|
||||
windows
|
||||
}
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn call_plugin(
|
||||
&self,
|
||||
window: String,
|
||||
project: Arc<crate::Project>,
|
||||
plugins: Arc<crate::plugins::PluginHost>,
|
||||
plugin: String,
|
||||
tool: String,
|
||||
metadata: ide_mcp::Tool,
|
||||
arguments: &str,
|
||||
cancel: CancellationToken,
|
||||
client: &str,
|
||||
) -> Result<String, ToolCallError> {
|
||||
let run = || -> ide_core::IdeResult<String> {
|
||||
let args = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let call = tool::Call {
|
||||
host: self.0.clone(),
|
||||
project: project.clone(),
|
||||
window: window.clone(),
|
||||
args,
|
||||
cancel: cancel.clone(),
|
||||
client: client.into(),
|
||||
tool: metadata.name,
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if metadata.destructive {
|
||||
self.0.runtime.block_on(call.confirm(&metadata.description, None))?;
|
||||
}
|
||||
if !metadata.read_only {
|
||||
project.require_trust()?;
|
||||
}
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
plugins.call_mcp_tool(&plugin, &tool, arguments, invocation, &cancel)
|
||||
};
|
||||
run().map_err(|e| ToolCallError::Failed(e.message))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn restricted_mode_excludes_execution_tools() {
|
||||
let mut tool = super::tools().remove(0).metadata;
|
||||
assert!(super::allowed(&tool, false));
|
||||
tool.read_only = false;
|
||||
assert!(!super::allowed(&tool, false));
|
||||
assert!(super::allowed(&tool, true));
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
//! Window-owned snapshots. A project close drops every report from that window.
|
||||
use ide_core::{Diagnostic, IpcPath, Range};
|
||||
use parking_lot::RwLock;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileProblems {
|
||||
pub path: IpcPath,
|
||||
pub items: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FocusedEditor {
|
||||
pub path: IpcPath,
|
||||
/// Zero-based lines and UTF-16 columns, end exclusive.
|
||||
pub selection: Range,
|
||||
pub selected_text: String,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Reports(RwLock<State>);
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
windows: HashMap<String, Window>,
|
||||
focused: Option<String>,
|
||||
remote: HashMap<String, PathBuf>,
|
||||
libraries: HashMap<String, Vec<PathBuf>>,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
editors: Vec<PathBuf>,
|
||||
focused: Option<FocusedEditor>,
|
||||
problems: BTreeMap<PathBuf, Vec<Diagnostic>>,
|
||||
}
|
||||
impl Reports {
|
||||
pub fn set_open_editors(&self, window: &str, paths: Vec<PathBuf>) {
|
||||
self.0.write().windows.entry(window.into()).or_default().editors = paths;
|
||||
}
|
||||
pub fn set_focused_editor(&self, window: &str, editor: Option<FocusedEditor>, focused: bool) {
|
||||
let mut state = self.0.write();
|
||||
state.windows.entry(window.into()).or_default().focused = editor;
|
||||
if focused {
|
||||
state.focused = Some(window.into());
|
||||
}
|
||||
}
|
||||
pub fn report_problems(&self, window: &str, reset: bool, files: Vec<FileProblems>) {
|
||||
let mut state = self.0.write();
|
||||
let problems = &mut state.windows.entry(window.into()).or_default().problems;
|
||||
if reset {
|
||||
problems.clear();
|
||||
}
|
||||
for file in files {
|
||||
let path = file.path.into();
|
||||
if file.items.is_empty() {
|
||||
problems.remove(&path);
|
||||
} else {
|
||||
problems.insert(path, file.items);
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn forget_window(&self, window: &str) {
|
||||
let mut state = self.0.write();
|
||||
state.windows.remove(window);
|
||||
state.remote.remove(window);
|
||||
state.libraries.remove(window);
|
||||
if state.focused.as_deref() == Some(window) {
|
||||
state.focused = None;
|
||||
}
|
||||
}
|
||||
pub fn set_remote(&self, window: &str, root: PathBuf) {
|
||||
self.0.write().remote.insert(window.into(), root);
|
||||
}
|
||||
pub fn remove_remote(&self, window: &str) {
|
||||
self.0.write().remote.remove(window);
|
||||
}
|
||||
pub(crate) fn set_libraries(&self, window: &str, roots: Vec<PathBuf>) {
|
||||
self.0.write().libraries.insert(window.into(), roots);
|
||||
}
|
||||
pub(super) fn libraries(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
windows.iter().filter_map(|w| state.libraries.get(w)).flatten().cloned().collect()
|
||||
}
|
||||
pub(super) fn remote_roots(&self) -> Vec<PathBuf> {
|
||||
self.0.read().remote.values().cloned().collect()
|
||||
}
|
||||
pub(super) fn focused_window(&self) -> Option<String> {
|
||||
self.0.read().focused.clone()
|
||||
}
|
||||
pub(crate) fn editors(&self, windows: &[String]) -> Vec<PathBuf> {
|
||||
let state = self.0.read();
|
||||
let mut paths = Vec::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
for path in &window.editors {
|
||||
if !paths.contains(path) {
|
||||
paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
pub(crate) fn focused(&self, window: &str) -> Option<FocusedEditor> {
|
||||
self.0.read().windows.get(window).and_then(|w| w.focused.clone())
|
||||
}
|
||||
pub(crate) fn problems(&self, windows: &[String]) -> Vec<FileProblems> {
|
||||
let state = self.0.read();
|
||||
let mut files: BTreeMap<PathBuf, Vec<Diagnostic>> = BTreeMap::new();
|
||||
for window in windows.iter().filter_map(|w| state.windows.get(w)) {
|
||||
for (path, items) in &window.problems {
|
||||
let found = files.entry(path.clone()).or_default();
|
||||
for item in items {
|
||||
if !found.contains(item) {
|
||||
found.push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files.into_iter().map(|(path, items)| FileProblems { path: path.into(), items }).collect()
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Window-bound requests; missing windows, cancellation and timeout all fail closed.
|
||||
use super::tool::Call;
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, event::McpUiRequest};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Requests(Mutex<State>);
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
ready: HashSet<String>,
|
||||
pending: HashMap<u32, Pending>,
|
||||
}
|
||||
struct Pending {
|
||||
window: String,
|
||||
answer: oneshot::Sender<Result<bool, String>>,
|
||||
}
|
||||
impl Requests {
|
||||
pub fn set_ready(&self, window: &str, ready: bool) {
|
||||
if ready {
|
||||
self.0.lock().ready.insert(window.into());
|
||||
} else {
|
||||
self.forget_window(window);
|
||||
}
|
||||
}
|
||||
pub fn forget_window(&self, window: &str) {
|
||||
let mut state = self.0.lock();
|
||||
state.ready.remove(window);
|
||||
state.pending.retain(|_, p| p.window != window);
|
||||
}
|
||||
pub fn answer(&self, window: &str, id: u32, allow: bool, error: Option<String>) -> IdeResult<()> {
|
||||
let mut state = self.0.lock();
|
||||
if !state.pending.get(&id).is_some_and(|p| p.window == window) {
|
||||
return Err(IdeError::invalid("MCP request expired or belongs to another window"));
|
||||
}
|
||||
let pending = state.pending.remove(&id).unwrap();
|
||||
let _ = pending.answer.send(error.map_or(Ok(allow), Err));
|
||||
Ok(())
|
||||
}
|
||||
pub(super) async fn ask(&self, call: &Call, request: McpUiRequest) -> IdeResult<()> {
|
||||
let id = super::tool::request_id().0;
|
||||
let (tx, rx) = oneshot::channel();
|
||||
{
|
||||
let mut state = self.0.lock();
|
||||
if !state.ready.contains(call.window()) {
|
||||
return Err(IdeError::unavailable("MCP request denied: no project window is available"));
|
||||
}
|
||||
state.pending.insert(id, Pending { window: call.window.clone(), answer: tx });
|
||||
}
|
||||
struct Close<'a>(&'a Requests, &'a Call, u32);
|
||||
impl Drop for Close<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.0.0.lock().pending.remove(&self.2);
|
||||
self.1.project.sink.emit(IdeEvent::McpRequestClosed { id: self.2 });
|
||||
}
|
||||
}
|
||||
let _close = Close(self, call, id);
|
||||
call.project.sink.emit(IdeEvent::McpRequest { id, request });
|
||||
let result = tokio::select! {
|
||||
result = rx => result.map_err(|_| IdeError::unavailable("MCP request denied: project window closed"))?,
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(180)) => return Err(IdeError::unavailable("MCP request denied: confirmation timed out")),
|
||||
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
match result {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => Err(IdeError::invalid("MCP request denied by the user")),
|
||||
Err(message) => Err(IdeError::unavailable(message)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server
|
||||
// (mcpserver.toolsets/src/general/AnalysisToolset.kt, build_project), Apache-2.0.
|
||||
//! `build_project`.
|
||||
|
||||
use super::output::{MAX_TEXT_LENGTH, TRUNCATED_MARKER, truncate_tail};
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_build::compile::{BuildEvent, BuildKind, BuildMessage, BuildResult};
|
||||
use ide_core::Severity;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 60_000;
|
||||
const MAX_TIMEOUT_MS: usize = 3_600_000;
|
||||
const MAX_PROBLEMS: usize = 300;
|
||||
/// Console lines attached to a failure whose messages were not parsed.
|
||||
const CONSOLE_LINES: usize = 200;
|
||||
|
||||
/// A JetBrains `ProjectProblem`: `kind` ERROR or WARNING, the compiler's code as `group`,
|
||||
/// 1-based `line` / `column` (JetBrains reports them 0-based).
|
||||
fn problem(call: &Call, m: &BuildMessage) -> Value {
|
||||
let mut v = json!({
|
||||
"message": m.text,
|
||||
"kind": if m.severity == Severity::Error { "ERROR" } else { "WARNING" },
|
||||
});
|
||||
if let Some(code) = &m.code {
|
||||
v["group"] = code.clone().into();
|
||||
}
|
||||
if let Some(path) = &m.path {
|
||||
v["file"] = call.display(path).into();
|
||||
}
|
||||
if let Some(range) = m.range {
|
||||
v["line"] = (range.start.line + 1).into();
|
||||
v["column"] = (range.start.col + 1).into();
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Runs the build of `target` (the whole project when `None`), its console into `console`.
|
||||
/// Returns `None` when `deadline` passed first (the build is then stopped).
|
||||
pub async fn build_once(
|
||||
call: &Call,
|
||||
kind: BuildKind,
|
||||
target: Option<PathBuf>,
|
||||
deadline: Instant,
|
||||
console: &Arc<Mutex<String>>,
|
||||
) -> IdeResult<Option<BuildResult>> {
|
||||
let host = call.host();
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let sink = console.clone();
|
||||
let channel = crate::ops::Channel::new(move |e: BuildEvent| match e {
|
||||
BuildEvent::Output { text, .. } => sink.lock().push_str(&text),
|
||||
BuildEvent::Step { command, .. } => sink.lock().push_str(&format!("$ {command}\n")),
|
||||
_ => {}
|
||||
});
|
||||
let build = crate::ops::build::build_run(&host, call.window(), kind, target, None, request, Vec::new(), channel);
|
||||
tokio::pin!(build);
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
tokio::select! {
|
||||
r = &mut build => r.map(Some),
|
||||
_ = tokio::time::sleep(remaining) => {
|
||||
host.cancel_request(request);
|
||||
let _ = build.await;
|
||||
Ok(None)
|
||||
}
|
||||
_ = call.cancel.cancelled() => {
|
||||
host.cancel_request(request);
|
||||
let _ = build.await;
|
||||
Err(IdeError::cancelled())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build_project() -> Tool {
|
||||
Tool::new(
|
||||
"build_project",
|
||||
"Triggers building of the project or specified files, waits for completion, and returns build errors.
|
||||
Use this tool to build the project or compile files and get detailed information about compilation errors and warnings.
|
||||
You have to use this tool after performing edits to validate if the edits are valid.
|
||||
In OpenIDEA the project's build system builds it (Maven, Gradle, Cargo, Go or TypeScript); with `filesToRebuild`, the \
|
||||
modules containing those files. Asks the user first for every call. Problems have the project-relative \
|
||||
`file` and 1-based `line` / `column`; at most 300 problems are listed, and `isSuccess` is false whenever one of them \
|
||||
is an ERROR. On timeout (at most 1 hour; 0 times out at once) the build is stopped.",
|
||||
|call| async move {
|
||||
let files: Vec<String> =
|
||||
call.str_list("filesToRebuild")?.into_iter().filter(|f| !f.trim().is_empty()).collect();
|
||||
let rebuild = call.flag("rebuild", false)? && files.is_empty();
|
||||
let kind = if rebuild { BuildKind::Rebuild } else { BuildKind::Build };
|
||||
let limit = timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?;
|
||||
let mut targets: Vec<Option<PathBuf>> = Vec::new();
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
for f in &files {
|
||||
let path = call.resolve_inside(f)?;
|
||||
if !path.exists() {
|
||||
return Err(IdeError::not_found(format!("File not found: {f}")));
|
||||
}
|
||||
let dir = if path.is_dir() { path.clone() } else { path.parent().unwrap_or(&path).to_path_buf() };
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
targets.push(Some(path));
|
||||
}
|
||||
}
|
||||
if targets.is_empty() {
|
||||
targets.push(None);
|
||||
}
|
||||
let subject = if rebuild { "Rebuild project" } else { "Build project" };
|
||||
let detail = targets.iter().flatten().map(|p| call.display(p)).collect::<Vec<_>>().join(", ");
|
||||
call.confirm(subject, (!detail.is_empty()).then(|| format!("modules of {detail}"))).await?;
|
||||
|
||||
if limit.is_zero() {
|
||||
// JetBrains' `withTimeoutOrNull(0)`: nothing is built.
|
||||
return Output::json(json!({ "isSuccess": false, "problems": [], "timedOut": true }));
|
||||
}
|
||||
let console = Arc::new(Mutex::new(String::new()));
|
||||
let deadline = Instant::now() + limit;
|
||||
let mut results: Vec<BuildResult> = Vec::new();
|
||||
let mut timed_out = false;
|
||||
for target in targets {
|
||||
match build_once(&call, kind, target, deadline, &console).await? {
|
||||
Some(r) => {
|
||||
let failed = !r.success;
|
||||
results.push(r);
|
||||
if failed {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
timed_out = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let messages: Vec<&BuildMessage> = results
|
||||
.iter()
|
||||
.flat_map(|r| r.messages.iter())
|
||||
.filter(|m| matches!(m.severity, Severity::Error | Severity::Warning))
|
||||
.collect();
|
||||
let mut problems: Vec<Value> = messages.iter().take(MAX_PROBLEMS).map(|m| problem(&call, m)).collect();
|
||||
let has_errors = messages.iter().any(|m| m.severity == Severity::Error);
|
||||
let failed = results.iter().any(|r| !r.success);
|
||||
if failed && !has_errors {
|
||||
let tail = truncate_tail(
|
||||
console.lock().trim_end(),
|
||||
CONSOLE_LINES,
|
||||
MAX_TEXT_LENGTH,
|
||||
TRUNCATED_MARKER,
|
||||
);
|
||||
problems.push(json!({
|
||||
"message": "Build reported errors, but detailed error messages were not captured through build events.",
|
||||
"kind": "ERROR",
|
||||
"description": tail,
|
||||
}));
|
||||
}
|
||||
let mut result = Map::new();
|
||||
result.insert("isSuccess".into(), is_success(timed_out, &results).into());
|
||||
result.insert("problems".into(), problems.into());
|
||||
if timed_out {
|
||||
result.insert("timedOut".into(), true.into());
|
||||
}
|
||||
Output::json(Value::Object(result))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::optional(
|
||||
"rebuild",
|
||||
ParamType::Boolean,
|
||||
"Whether to perform full rebuild the project. Defaults to false. Effective only when `filesToRebuild` is not \
|
||||
specified.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"filesToRebuild",
|
||||
ParamType::StringList,
|
||||
"If specified, only compile files with the specified paths. Paths are relative to the project root.",
|
||||
))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
|
||||
}
|
||||
|
||||
/// JetBrains' `isSuccess`: finished in time, every build succeeded and no ERROR was reported
|
||||
/// (a build tool may exit 0 after printing errors).
|
||||
fn is_success(timed_out: bool, results: &[BuildResult]) -> bool {
|
||||
!timed_out && results.iter().all(|r| r.success && r.messages.iter().all(|m| m.severity != Severity::Error))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn result(success: bool, severities: &[Severity]) -> BuildResult {
|
||||
BuildResult {
|
||||
system: ide_build::compile::BuildSystem::Cargo,
|
||||
title: "build".into(),
|
||||
success,
|
||||
errors: 0,
|
||||
warnings: 0,
|
||||
duration_ms: 0.0,
|
||||
messages: severities
|
||||
.iter()
|
||||
.map(|s| BuildMessage { severity: *s, text: "m".into(), path: None, range: None, code: None })
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_error_problem_fails_the_build() {
|
||||
assert!(is_success(false, &[result(true, &[Severity::Warning])]));
|
||||
assert!(!is_success(false, &[result(true, &[Severity::Warning, Severity::Error])]));
|
||||
assert!(!is_success(false, &[result(false, &[])]));
|
||||
assert!(!is_success(true, &[result(true, &[])]));
|
||||
}
|
||||
}
|
||||
@@ -1,709 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/ExecutionToolset.kt,
|
||||
// mcpserver.toolsets/src/util/execution.util.kt, mcpserver.toolsets/src/util/RunConfigurationBuildErrors.kt), Apache-2.0.
|
||||
//! `get_run_configurations` and `execute_run_configuration`.
|
||||
|
||||
use super::build::build_once;
|
||||
use super::output::OutputCollector;
|
||||
use super::process::{quote, split_words};
|
||||
use super::targets;
|
||||
use crate::Project;
|
||||
use crate::mcp::run::timeout;
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_build::compile::BuildKind;
|
||||
use ide_core::{IdeError, IdeResult, RunId, Severity};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const DEFAULT_TIMEOUT_MS: usize = 10_000;
|
||||
/// No limit in JetBrains; the process keeps running after the timeout anyway.
|
||||
const MAX_TIMEOUT_MS: usize = 1_800_000;
|
||||
/// Output of a failed before-launch process kept for the error message.
|
||||
const MAX_PROCESS_OUTPUT_LENGTH: usize = 20_000;
|
||||
/// A before-launch build gets this long.
|
||||
const BEFORE_LAUNCH_BUILD_TIMEOUT: Duration = Duration::from_secs(3600);
|
||||
|
||||
async fn load_configs(call: &Call) -> IdeResult<Vec<RunConfig>> {
|
||||
let host = call.host();
|
||||
let window = call.window().to_owned();
|
||||
let h = host.clone();
|
||||
host.blocking(move || crate::ops::run::configs(&h, &window)).await
|
||||
}
|
||||
|
||||
fn supports_overrides(config: &RunConfig) -> bool {
|
||||
config.extras.compound.is_empty() && !config.command.trim().is_empty()
|
||||
}
|
||||
|
||||
fn command_line(config: &RunConfig) -> String {
|
||||
std::iter::once(config.command.clone()).chain(config.args.iter().map(|a| quote(a))).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn describe_config(call: &Call, c: &RunConfig) -> Value {
|
||||
let mut v = json!({
|
||||
"name": c.name,
|
||||
"description": c.kind,
|
||||
"supportsDynamicLaunchOverrides": supports_overrides(c),
|
||||
});
|
||||
if supports_overrides(c) {
|
||||
v["commandLine"] = command_line(c).into();
|
||||
if let Some(cwd) = c.cwd.as_deref() {
|
||||
v["workingDirectory"] = call.display(&call.root().join(cwd)).into();
|
||||
}
|
||||
if !c.env.is_empty() {
|
||||
v["environment"] = json!(c.env);
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A file of the project to look for run points in: its `/`-separated project-relative path
|
||||
/// and current text (unsaved edits included).
|
||||
fn read_file(project: &Project, path: &Path, file: &str) -> IdeResult<(String, String)> {
|
||||
let meta = std::fs::metadata(path).map_err(|_| IdeError::not_found(format!("File not found: {file}")))?;
|
||||
if !meta.is_file() {
|
||||
return Err(IdeError::invalid(format!("Not a file: {file}")));
|
||||
}
|
||||
let rel = path.strip_prefix(&project.root).unwrap_or(path).to_string_lossy().replace('\\', "/");
|
||||
let text = match project.documents.text_of(path) {
|
||||
Some(text) => text,
|
||||
None => std::fs::read_to_string(path).map_err(|e| IdeError::invalid(format!("Cannot read {file}: {e}")))?,
|
||||
};
|
||||
Ok((rel, text))
|
||||
}
|
||||
|
||||
/// Reads `file` and runs `scan` (root, relative path, text) over it off the async threads:
|
||||
/// the run point patterns go over up to 400 KB of text.
|
||||
async fn scan_file<T: Send + 'static>(
|
||||
call: &Call,
|
||||
file: &str,
|
||||
scan: impl FnOnce(&Path, &str, &str) -> IdeResult<T> + Send + 'static,
|
||||
) -> IdeResult<T> {
|
||||
let path = call.resolve_inside(file)?;
|
||||
let project = call.project().clone();
|
||||
let file = file.to_owned();
|
||||
call.host()
|
||||
.blocking(move || {
|
||||
let (rel, text) = read_file(&project, &path, &file)?;
|
||||
scan(&project.root, &rel, &text)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn get_run_configurations() -> Tool {
|
||||
Tool::new(
|
||||
"get_run_configurations",
|
||||
"Returns either project run configurations or executable code locations, depending on the input.
|
||||
|
||||
Without `filePath`, this tool lists the project's existing run configurations. The result includes configuration
|
||||
names and, when available, launch details such as program arguments, working directory, environment variables,
|
||||
and `supportsDynamicLaunchOverrides`.
|
||||
|
||||
`supportsDynamicLaunchOverrides` is the source-of-truth capability flag for one-time launch overrides
|
||||
(`programArguments`, `workingDirectory`, `envs`) in `execute_run_configuration` and `xdebug_start_debugger_session`.
|
||||
Only pass those override parameters when this flag is `true` for the selected configuration.
|
||||
|
||||
With `filePath`, this tool discovers executable entry points (run points) in that file, such as test methods,
|
||||
main methods, or other executable entry points where the IDE shows a Run gutter icon. The result contains `filePath` and
|
||||
`runPoints`; use the returned line numbers with `execute_run_configuration` to run from code.
|
||||
In OpenIDEA the configurations are the saved ones and those detected from the build files (package.json scripts, \
|
||||
Cargo targets, Gradle/Maven tasks, Go, Python, Makefile…); `description` is the configuration type and `commandLine` \
|
||||
the whole command. Run points are found in Rust, JavaScript/TypeScript, Python and Go files.",
|
||||
|call| async move {
|
||||
let configs = load_configs(&call).await?;
|
||||
if let Some(file) = call.opt_str("filePath")? {
|
||||
let points = scan_file(&call, &file, move |root, rel, text| {
|
||||
let mut points: Vec<Value> = Vec::new();
|
||||
let mut lines = std::collections::HashSet::new();
|
||||
for t in targets::detect(text, rel) {
|
||||
if lines.contains(&t.line) {
|
||||
continue;
|
||||
}
|
||||
if let Some(config) = targets::target_config(root, &t, rel, &configs, text) {
|
||||
lines.insert(t.line);
|
||||
points.push(json!({
|
||||
"line": t.line + 1,
|
||||
"description": format!("Run '{}'", config.name),
|
||||
"elementText": t.name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
Ok(points)
|
||||
})
|
||||
.await?;
|
||||
return Output::json(json!({ "filePath": file, "runPoints": points }));
|
||||
}
|
||||
let list: Vec<Value> = configs.iter().map(|c| describe_config(&call, c)).collect();
|
||||
Output::json(json!({ "configurations": list }))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::optional(
|
||||
"filePath",
|
||||
ParamType::String,
|
||||
"Optional file path relative to the project root. When provided, returns run points (executable entry points) \
|
||||
in the file instead of project-wide run configurations.",
|
||||
))
|
||||
}
|
||||
|
||||
/// What `execute_run_configuration` runs.
|
||||
enum ExecutionTarget {
|
||||
ByName(String),
|
||||
ByContext(String, i64),
|
||||
}
|
||||
|
||||
fn execution_target(name: Option<String>, file: Option<String>, line: Option<i64>) -> IdeResult<ExecutionTarget> {
|
||||
let name = name.filter(|n| !n.trim().is_empty());
|
||||
let file = file.filter(|f| !f.trim().is_empty());
|
||||
match (name, file, line) {
|
||||
(Some(_), f, l) if f.is_some() || l.is_some() => {
|
||||
Err(IdeError::invalid("Pass either configurationName or filePath + line, but not both."))
|
||||
}
|
||||
(Some(name), _, _) => Ok(ExecutionTarget::ByName(name)),
|
||||
(None, Some(file), Some(line)) => Ok(ExecutionTarget::ByContext(file, line)),
|
||||
(None, Some(_), None) | (None, None, Some(_)) => {
|
||||
Err(IdeError::invalid("Pass both filePath and line together, or use configurationName."))
|
||||
}
|
||||
(None, None, None) => Err(IdeError::invalid("Pass either configurationName or filePath + line.")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The temporary configuration of the run point at `file:line` (1-based): the innermost target
|
||||
/// enclosing that line (as JetBrains walks up to the enclosing test), else the whole file's
|
||||
/// (its tests, its main function).
|
||||
async fn config_from_code_location(
|
||||
call: &Call,
|
||||
configs: Vec<RunConfig>,
|
||||
file: &str,
|
||||
line: i64,
|
||||
) -> IdeResult<RunConfig> {
|
||||
let file_name = file.to_owned();
|
||||
scan_file(call, file, move |root, rel, text| {
|
||||
if line < 1 {
|
||||
return Err(IdeError::invalid(format!("Line number must be >= 1, got: {line}")));
|
||||
}
|
||||
let count = text.split('\n').count();
|
||||
if line as usize > count {
|
||||
return Err(IdeError::invalid(format!("Line {line} is out of range (file has {count} lines)")));
|
||||
}
|
||||
targets::enclosing(text, rel, line as usize - 1)
|
||||
.iter()
|
||||
.find_map(|t| targets::target_config(root, t, rel, &configs, text))
|
||||
.or_else(|| targets::context_config(root, rel, &configs, text))
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found(format!(
|
||||
"No run configuration could be created from {file_name}:{line}. Use get_run_configurations with \
|
||||
filePath to find valid run locations."
|
||||
))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Applies the one-off overrides (JetBrains' `prepareRunConfigurationForExecution`): a missing or
|
||||
/// empty string keeps the value, a whitespace-only one clears it; `envs` merge over the configuration's.
|
||||
fn apply_overrides(
|
||||
config: &mut RunConfig,
|
||||
root: &Path,
|
||||
args: Option<String>,
|
||||
cwd: Option<String>,
|
||||
envs: Option<Vec<(String, String)>>,
|
||||
) -> IdeResult<()> {
|
||||
let args = args.filter(|a| !a.is_empty());
|
||||
let cwd = cwd.filter(|d| !d.is_empty());
|
||||
if args.is_none() && cwd.is_none() && envs.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
if !supports_overrides(config) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Run configuration '{}' of type '{}' doesn't support dynamic launch overrides (programArguments, \
|
||||
workingDirectory, envs).",
|
||||
config.name,
|
||||
if config.extras.compound.is_empty() { config.kind.as_str() } else { "Compound" }
|
||||
)));
|
||||
}
|
||||
if let Some(args) = args {
|
||||
let words = split_words(&args)?;
|
||||
// A configuration that is one shell line (`npm run dev`, no separate arguments)
|
||||
// keeps its command and gets the new line's arguments appended.
|
||||
let shell_line = config.args.is_empty() && config.command.trim().contains(char::is_whitespace);
|
||||
if shell_line {
|
||||
if !words.is_empty() {
|
||||
config.command = format!("{} {}", config.command.trim(), args.trim());
|
||||
}
|
||||
} else {
|
||||
config.args = words;
|
||||
}
|
||||
}
|
||||
if let Some(dir) = cwd {
|
||||
if dir.trim().is_empty() {
|
||||
config.cwd = None;
|
||||
} else {
|
||||
let path = crate::mcp::tool::resolve_inside(root, &dir)?;
|
||||
if !path.is_dir() {
|
||||
return Err(IdeError::not_found(format!("workingDirectory {} is not a directory", path.display())));
|
||||
}
|
||||
config.cwd = Some(path.into());
|
||||
}
|
||||
}
|
||||
config.env.extend(envs.into_iter().flatten());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls how run `id` ended: `None` while running, else its exit code (if known).
|
||||
fn run_exit(host: &crate::Host, id: RunId) -> Option<Option<i32>> {
|
||||
match host.processes.exit_code(id) {
|
||||
Ok(exit) => exit,
|
||||
// Remote (SSH) runs, or a finished run already forgotten: the run list knows whether it runs.
|
||||
Err(_) => match crate::ops::run::list(host).into_iter().find(|r| r.id == id) {
|
||||
Some(r) if r.running => None,
|
||||
_ => Some(None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Waits for run `id` to exit, until `deadline`: `None` when it is still running. Cancelling
|
||||
/// the call stops the run.
|
||||
async fn wait_exit(call: &Call, id: RunId, deadline: Option<Instant>) -> IdeResult<Option<Option<i32>>> {
|
||||
let host = call.host();
|
||||
loop {
|
||||
if let Some(exit) = run_exit(&host, id) {
|
||||
return Ok(Some(exit));
|
||||
}
|
||||
if deadline.is_some_and(|d| Instant::now() >= d) {
|
||||
return Ok(None);
|
||||
}
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_millis(50)) => {}
|
||||
_ = call.cancel.cancelled() => {
|
||||
let _ = crate::ops::run::stop(&host, call.window(), id);
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An output callback that skips the IDE's own lines (`Process finished…`), as JetBrains
|
||||
/// skips `ProcessOutputType.SYSTEM`.
|
||||
fn process_output(mut f: impl FnMut(&str) + Send + 'static) -> crate::ops::Emit<OutputChunk> {
|
||||
let f = Mutex::new(move |t: &str| f(t));
|
||||
Arc::new(move |chunk: OutputChunk| match &chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } => (f.lock())(text),
|
||||
OutputChunk::System { .. } => {}
|
||||
})
|
||||
}
|
||||
|
||||
/// What failed before launch (JetBrains' `RunConfigurationBuildErrors`): build errors as
|
||||
/// `path:line: message`, else the output of before-launch processes that failed.
|
||||
#[derive(Default)]
|
||||
struct BuildErrors {
|
||||
errors: Vec<String>,
|
||||
process_outputs: Vec<String>,
|
||||
}
|
||||
|
||||
impl BuildErrors {
|
||||
fn text(&self) -> Option<String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let errors: Vec<&String> = self.errors.iter().filter(|e| seen.insert(*e)).collect();
|
||||
if !errors.is_empty() {
|
||||
return Some(errors.iter().map(|e| e.as_str()).collect::<Vec<_>>().join("\n"));
|
||||
}
|
||||
let outputs: Vec<&str> = self.process_outputs.iter().map(|o| o.trim()).filter(|o| !o.is_empty()).collect();
|
||||
(!outputs.is_empty()).then(|| outputs.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `config` to its end; `Ok(false)` (with its output recorded) when it failed.
|
||||
async fn run_to_end(call: &Call, config: RunConfig, errors: &mut BuildErrors) -> IdeResult<bool> {
|
||||
let tail = Arc::new(Mutex::new(String::new()));
|
||||
let sink = tail.clone();
|
||||
let emit = process_output(move |text| {
|
||||
let mut t = sink.lock();
|
||||
t.push_str(text);
|
||||
if t.len() > 2 * MAX_PROCESS_OUTPUT_LENGTH {
|
||||
let mut cut = t.len() - MAX_PROCESS_OUTPUT_LENGTH;
|
||||
while !t.is_char_boundary(cut) {
|
||||
cut += 1;
|
||||
}
|
||||
t.drain(..cut);
|
||||
}
|
||||
});
|
||||
let info = crate::ops::run::start(&call.host(), call.window(), config, emit).await?;
|
||||
let exit = wait_exit(call, info.id, None).await?.flatten();
|
||||
if exit == Some(0) {
|
||||
return Ok(true);
|
||||
}
|
||||
let t = tail.lock();
|
||||
let start = t.char_indices().rev().nth(MAX_PROCESS_OUTPUT_LENGTH - 1).map_or(0, |(i, _)| i);
|
||||
errors.process_outputs.push(t[start..].to_owned());
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// The configuration's before-launch tasks, as the Run tool window runs them; returns why
|
||||
/// the launch is aborted, if it is. Tool windows are not activated.
|
||||
async fn before_launch(
|
||||
call: &Call,
|
||||
config: &RunConfig,
|
||||
configs: &[RunConfig],
|
||||
chain: &mut Vec<String>,
|
||||
errors: &mut BuildErrors,
|
||||
) -> IdeResult<Option<String>> {
|
||||
chain.push(config.id.clone());
|
||||
for task in &config.extras.before_launch {
|
||||
let target = task.target.as_str();
|
||||
let (label, failure) = match task.kind.as_str() {
|
||||
"build" => {
|
||||
let console = Arc::new(Mutex::new(String::new()));
|
||||
let deadline = Instant::now() + BEFORE_LAUNCH_BUILD_TIMEOUT;
|
||||
let failure = match build_once(call, BuildKind::Build, None, deadline, &console).await {
|
||||
Ok(Some(r)) => {
|
||||
for m in r.messages.iter().filter(|m| m.severity == Severity::Error) {
|
||||
errors.errors.push(match (&m.path, m.range) {
|
||||
(Some(p), Some(r)) => format!("{}:{}: {}", p.display(), r.start.line + 1, m.text),
|
||||
_ => m.text.clone(),
|
||||
});
|
||||
}
|
||||
(!r.success).then(|| "Build failed".to_owned())
|
||||
}
|
||||
Ok(None) => Some("The build timed out".to_owned()),
|
||||
Err(e) => Some(e.message),
|
||||
};
|
||||
("Build Project".to_owned(), failure)
|
||||
}
|
||||
"run" => {
|
||||
let other = configs.iter().find(|c| c.id == target);
|
||||
let label = format!("Run '{}'", other.map_or(target, |c| c.name.as_str()));
|
||||
let failure = if chain.iter().any(|c| c == target) {
|
||||
Some("The before launch tasks run in a cycle".to_owned())
|
||||
} else if let Some(other) = other {
|
||||
let ok = Box::pin(before_launch(call, other, configs, chain, errors)).await?.is_none()
|
||||
&& run_to_end(call, other.clone(), errors).await?;
|
||||
(!ok).then(|| format!("'{}' failed", other.name))
|
||||
} else {
|
||||
Some(format!("Configuration '{target}' no longer exists"))
|
||||
};
|
||||
(label, failure)
|
||||
}
|
||||
"command" => {
|
||||
let failure = if target.trim().is_empty() {
|
||||
Some("No command".to_owned())
|
||||
} else {
|
||||
let run = RunConfig {
|
||||
id: format!("before-launch:{}:{target}", config.id),
|
||||
name: target.to_owned(),
|
||||
kind: "shell".into(),
|
||||
command: target.to_owned(),
|
||||
args: Vec::new(),
|
||||
cwd: config.cwd.clone(),
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
(!run_to_end(call, run, errors).await?).then(|| format!("'{target}' failed"))
|
||||
};
|
||||
(format!("Run '{target}'"), failure)
|
||||
}
|
||||
"toolWindow" => continue,
|
||||
"tool" => (format!("External tool '{target}'"), Some("External tools cannot run through MCP".to_owned())),
|
||||
other => (other.to_owned(), Some(format!("Unknown before launch task '{other}'"))),
|
||||
};
|
||||
if let Some(failure) = failure {
|
||||
chain.pop();
|
||||
return Ok(Some(format!("Before launch: {label}: {failure}")));
|
||||
}
|
||||
}
|
||||
chain.pop();
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The before-launch tasks of `config` as the confirmation lists them, nested ones indented.
|
||||
fn describe_before_launch(
|
||||
config: &RunConfig,
|
||||
configs: &[RunConfig],
|
||||
depth: usize,
|
||||
chain: &mut Vec<String>,
|
||||
out: &mut String,
|
||||
) {
|
||||
chain.push(config.id.clone());
|
||||
let indent = " ".repeat(depth + 1);
|
||||
for task in &config.extras.before_launch {
|
||||
let target = task.target.as_str();
|
||||
match task.kind.as_str() {
|
||||
"build" => out.push_str(&format!("\n{indent}Build Project")),
|
||||
"command" => out.push_str(&format!("\n{indent}Run command: {target}")),
|
||||
"run" => match configs.iter().find(|c| c.id == target) {
|
||||
Some(other) => {
|
||||
let line = if other.extras.compound.is_empty() { command_line(other) } else { "compound".into() };
|
||||
out.push_str(&format!("\n{indent}Run '{}': {line}", other.name));
|
||||
if !chain.contains(&other.id) {
|
||||
describe_before_launch(other, configs, depth + 1, chain, out);
|
||||
}
|
||||
}
|
||||
None => out.push_str(&format!("\n{indent}Run '{target}' (missing)")),
|
||||
},
|
||||
"toolWindow" => {}
|
||||
"tool" => out.push_str(&format!("\n{indent}External tool '{target}'")),
|
||||
other => out.push_str(&format!("\n{indent}{other} '{target}'")),
|
||||
}
|
||||
}
|
||||
chain.pop();
|
||||
}
|
||||
|
||||
/// What the user approves for `config` besides its command line: everything else that runs
|
||||
/// or applies — before-launch tasks, the stored environment (for a configuration made from
|
||||
/// code, the one of the configuration it derives from), `.env` files, the `envs` overrides
|
||||
/// (`NODE_OPTIONS`, `PATH`, ...), the stop command and a remote run target.
|
||||
fn confirmation_detail(
|
||||
config: &RunConfig,
|
||||
cwd: &str,
|
||||
stored_env: &std::collections::BTreeMap<String, String>,
|
||||
overrides: &[(String, String)],
|
||||
configs: &[RunConfig],
|
||||
) -> String {
|
||||
let mut detail = command_line(config);
|
||||
if !cwd.is_empty() {
|
||||
detail.push_str(&format!("\nin {cwd}"));
|
||||
}
|
||||
if let Some(target) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
detail.push_str(&format!("\non run target {target}"));
|
||||
}
|
||||
let mut tasks = String::new();
|
||||
describe_before_launch(config, configs, 0, &mut Vec::new(), &mut tasks);
|
||||
if !tasks.is_empty() {
|
||||
detail.push_str(&format!("\nbefore launch:{tasks}"));
|
||||
}
|
||||
if !config.extras.env_files.is_empty() {
|
||||
detail.push_str(&format!("\nenv files: {}", config.extras.env_files.join(", ")));
|
||||
}
|
||||
if !config.extras.include_system_env {
|
||||
detail.push_str("\nwithout the system environment");
|
||||
}
|
||||
if !stored_env.is_empty() {
|
||||
detail.push_str("\nenvironment:");
|
||||
for (k, v) in stored_env {
|
||||
detail.push_str(&format!("\n {k}={v}"));
|
||||
}
|
||||
}
|
||||
if !overrides.is_empty() {
|
||||
detail.push_str("\nenvironment overrides:");
|
||||
for (k, v) in overrides {
|
||||
detail.push_str(&format!("\n {k}={v}"));
|
||||
}
|
||||
}
|
||||
if let Some(stop) = config.extras.stop_command.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
detail.push_str(&format!("\non stop: {stop}"));
|
||||
}
|
||||
detail
|
||||
}
|
||||
|
||||
fn execution_failed(details: &[Option<String>]) -> IdeError {
|
||||
let details: Vec<&str> = details.iter().flatten().map(String::as_str).collect();
|
||||
IdeError::invalid(format!("Execution failed: {}", details.join("\n")))
|
||||
}
|
||||
|
||||
pub fn execute_run_configuration() -> Tool {
|
||||
Tool::new(
|
||||
"execute_run_configuration",
|
||||
"Run either an existing run configuration by name or a temporary run configuration created from a code location \
|
||||
(`filePath` + `line`) in the current project, then wait up to specified timeout for it to finish.
|
||||
|
||||
Use this tool with either a configuration name returned by `get_run_configurations`, or with a run point
|
||||
(`filePath` + `line`) returned by `get_run_configurations(filePath = ...)`.
|
||||
|
||||
Optional launch overrides (`programArguments`, `workingDirectory`, `envs`) are applied only for this run and are not persisted.
|
||||
Do not pass these override parameters unless you explicitly need to change the configured launch values for this run.
|
||||
Missing/null override parameters keep existing run configuration values unchanged.
|
||||
For string overrides (`programArguments`, `workingDirectory`), missing/null or empty string (`\"\"`) keeps the existing value unchanged.
|
||||
Pass a whitespace-only string such as `\" \"` to clear an existing value for this launch.
|
||||
|
||||
Pass either `configurationName`, or `filePath` together with `line`. These modes are mutually exclusive.
|
||||
|
||||
Behavior:
|
||||
- When `waitForExit=true`, waits up to `timeout` milliseconds for process termination. If the timeout expires,
|
||||
the process keeps running in the background and `exitCode` is omitted from the result.
|
||||
- When `waitForExit=false`, waits only for the process to start, then returns immediately without applying `timeout`.
|
||||
- `fullOutputPath` points to a temp file with the full raw output and may continue growing while the process is alive.
|
||||
|
||||
Returns the execution result including current output snapshot, optional exit code, and optional `fullOutputPath`.
|
||||
In OpenIDEA the user is asked first for every call; `programArguments` replace the configuration's \
|
||||
arguments (appended to a command that is one shell line, such as `npm run dev`); the run does not appear in the Run \
|
||||
tool window; output files are private and deleted after a day.",
|
||||
|call| async move {
|
||||
let target = execution_target(
|
||||
call.opt_str("configurationName")?,
|
||||
call.opt_str("filePath")?,
|
||||
call.opt_i64("line")?,
|
||||
)?;
|
||||
let wait = call.flag("waitForExit", true)?;
|
||||
// As JetBrains: `timeout` is ignored without waiting, and 0 returns without waiting.
|
||||
let limit = if wait { Some(timeout(&call, DEFAULT_TIMEOUT_MS, MAX_TIMEOUT_MS)?) } else { None };
|
||||
let envs = call.args.get("envs").filter(|v| !v.is_null()).map(|_| call.str_map("envs")).transpose()?;
|
||||
let configs = load_configs(&call).await?;
|
||||
let mut config = match &target {
|
||||
ExecutionTarget::ByName(name) => configs
|
||||
.iter()
|
||||
.find(|c| c.name == *name)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("Run configuration with name '{name}' not found.")))?,
|
||||
ExecutionTarget::ByContext(file, line) => {
|
||||
config_from_code_location(&call, configs.clone(), file, *line).await?
|
||||
}
|
||||
};
|
||||
let stored_env = config.env.clone();
|
||||
let overrides: Vec<(String, String)> = envs.clone().unwrap_or_default();
|
||||
apply_overrides(
|
||||
&mut config,
|
||||
call.root(),
|
||||
call.opt_str("programArguments")?,
|
||||
call.opt_str("workingDirectory")?,
|
||||
envs,
|
||||
)?;
|
||||
if !config.extras.compound.is_empty() {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"`{}` is a compound configuration, which cannot run through MCP; run its configurations ({}) \
|
||||
one by one",
|
||||
config.name,
|
||||
config.extras.compound.join(", ")
|
||||
)));
|
||||
}
|
||||
let cwd = match config.cwd.as_deref() {
|
||||
Some(p) => call.root().join(p),
|
||||
None => call.root().to_path_buf(),
|
||||
};
|
||||
let detail = confirmation_detail(&config, &call.display(&cwd), &stored_env, &overrides, &configs);
|
||||
call.confirm(&config.name, Some(detail)).await?;
|
||||
|
||||
let mut errors = BuildErrors::default();
|
||||
if let Some(reason) = before_launch(&call, &config, &configs, &mut Vec::new(), &mut errors).await? {
|
||||
return Err(execution_failed(&[errors.text(), Some(reason)]));
|
||||
}
|
||||
let collector = Arc::new(Mutex::new(OutputCollector::create(&config.name)?));
|
||||
let output_path = collector.lock().path.clone();
|
||||
let sink = collector.clone();
|
||||
let emit = process_output(move |text| sink.lock().append(text));
|
||||
let info = match crate::ops::run::start(&call.host(), call.window(), config, emit).await {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&output_path);
|
||||
return Err(execution_failed(&[errors.text(), Some(e.message)]));
|
||||
}
|
||||
};
|
||||
|
||||
let exit = match limit {
|
||||
Some(limit) if !limit.is_zero() => wait_exit(&call, info.id, Some(Instant::now() + limit)).await?,
|
||||
_ => None,
|
||||
};
|
||||
let c = collector.lock();
|
||||
let mut result = Map::new();
|
||||
if let Some(Some(code)) = exit {
|
||||
result.insert("exitCode".into(), code.into());
|
||||
}
|
||||
result.insert("output".into(), c.preview().into());
|
||||
// An exited run whose whole output is in `output` needs no file.
|
||||
if exit.is_none() || c.truncated {
|
||||
result.insert("fullOutputPath".into(), c.path.to_string_lossy().into_owned().into());
|
||||
} else {
|
||||
let _ = std::fs::remove_file(&c.path);
|
||||
}
|
||||
Output::json(Value::Object(result))
|
||||
},
|
||||
)
|
||||
.write()
|
||||
.param(Param::optional("configurationName", ParamType::String, "Name of the existing run configuration to execute"))
|
||||
.param(Param::optional(
|
||||
"filePath",
|
||||
ParamType::String,
|
||||
"File path relative to the project root. Provide together with `line` to create and execute a temporary run \
|
||||
configuration from code context.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"line",
|
||||
ParamType::Integer,
|
||||
"1-based line number for `filePath`. Provide together with `filePath` and do not combine with \
|
||||
`configurationName`.",
|
||||
))
|
||||
.param(Param::optional("timeout", ParamType::Integer, "Timeout in milliseconds"))
|
||||
.param(Param::optional(
|
||||
"waitForExit",
|
||||
ParamType::Boolean,
|
||||
"Whether to wait for process termination. If false, the tool returns immediately after the process starts and \
|
||||
ignores `timeout`.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"programArguments",
|
||||
ParamType::String,
|
||||
"Optional program arguments override for this launch only. Missing/null or empty string keeps the existing \
|
||||
value; whitespace-only string clears it.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"workingDirectory",
|
||||
ParamType::String,
|
||||
"Optional working directory override for this launch only. Missing/null or empty string keeps the existing \
|
||||
value; whitespace-only string clears it.",
|
||||
))
|
||||
.param(Param::optional(
|
||||
"envs",
|
||||
ParamType::StringMap,
|
||||
"Optional environment variable overrides for this launch only. Missing/null keeps existing env unchanged; when \
|
||||
provided, values are merged over existing env.",
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn config() -> RunConfig {
|
||||
RunConfig {
|
||||
id: "user:x".into(),
|
||||
name: "x".into(),
|
||||
kind: "shell".into(),
|
||||
command: "prog".into(),
|
||||
args: vec!["a".into()],
|
||||
cwd: Some(std::path::PathBuf::from("sub").into()),
|
||||
env: [("A".to_owned(), "1".to_owned())].into(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_overrides_keep_and_blank_ones_clear() {
|
||||
let root = Path::new("/");
|
||||
let mut c = config();
|
||||
apply_overrides(&mut c, root, Some(String::new()), Some(String::new()), None).unwrap();
|
||||
assert_eq!(c, config());
|
||||
apply_overrides(&mut c, root, Some(" ".into()), Some(" ".into()), Some(vec![("C".into(), "3".into())]))
|
||||
.unwrap();
|
||||
assert!(c.args.is_empty());
|
||||
assert!(c.cwd.is_none());
|
||||
assert_eq!(c.env.keys().collect::<Vec<_>>(), ["A", "C"]);
|
||||
apply_overrides(&mut c, root, Some("x 'y z'".into()), None, None).unwrap();
|
||||
assert_eq!(c.args, ["x", "y z"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_targets_are_exclusive() {
|
||||
let err = |n: Option<&str>, f: Option<&str>, l| {
|
||||
execution_target(n.map(Into::into), f.map(Into::into), l).err().map(|e| e.message)
|
||||
};
|
||||
assert_eq!(
|
||||
err(Some("a"), Some("f"), Some(1)).as_deref(),
|
||||
Some("Pass either configurationName or filePath + line, but not both.")
|
||||
);
|
||||
assert_eq!(
|
||||
err(None, Some("f"), None).as_deref(),
|
||||
Some("Pass both filePath and line together, or use configurationName.")
|
||||
);
|
||||
assert_eq!(err(None, None, None).as_deref(), Some("Pass either configurationName or filePath + line."));
|
||||
assert!(err(Some("a"), None, None).is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
//! Run configurations and builds; every call asks the user first.
|
||||
|
||||
mod build;
|
||||
mod configs;
|
||||
mod output;
|
||||
mod process;
|
||||
mod targets;
|
||||
|
||||
use crate::mcp::tool::{Call, Tool};
|
||||
use ide_core::IdeResult;
|
||||
use std::time::Duration;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![configs::get_run_configurations(), configs::execute_run_configuration(), build::build_project()]
|
||||
}
|
||||
|
||||
/// The `timeout` argument: `default` when absent, at most `max` milliseconds. As in JetBrains
|
||||
/// (`withTimeoutOrNull`), zero or a negative value times out at once.
|
||||
fn timeout(call: &Call, default: usize, max: usize) -> IdeResult<Duration> {
|
||||
Ok(match call.opt_i64("timeout")? {
|
||||
None => Duration::from_millis(default as u64),
|
||||
Some(n) if n <= 0 => Duration::ZERO,
|
||||
Some(n) => Duration::from_millis((n as u64).min(max as u64)),
|
||||
})
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/text.util.kt,
|
||||
// mcpserver.toolsets/src/util/OutputCollector.kt), Apache-2.0.
|
||||
//! Output of commands and runs: truncation, the run output collector and the private
|
||||
//! directory of full output files.
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use std::io::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Marker of text cut by [`truncate_tail`].
|
||||
pub const TRUNCATED_MARKER: &str = "<<<...content truncated...>>>";
|
||||
/// Longest text [`truncate_tail`] returns (before its marker).
|
||||
pub const MAX_TEXT_LENGTH: usize = 64 * 1024;
|
||||
/// Characters of a run's output returned inline.
|
||||
pub const RUN_PREVIEW_MAX_LENGTH: usize = 10_000;
|
||||
pub const RUN_PREVIEW_TRUNCATED_MARKER: &str = "<truncated>";
|
||||
|
||||
/// Console tail bounded by line count and character count.
|
||||
pub fn truncate_tail(text: &str, max_lines: usize, max_length: usize, marker: &str) -> String {
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let text = if lines.len() > max_lines {
|
||||
[vec![marker], lines[lines.len() - (max_lines - 1)..].to_vec()].concat().join("\n")
|
||||
} else {
|
||||
text.to_owned()
|
||||
};
|
||||
if text.chars().count() <= max_length {
|
||||
return text;
|
||||
}
|
||||
let start = text.char_indices().rev().nth(max_length - 1).map_or(0, |(i, _)| i);
|
||||
format!("{marker}{}", &text[start..])
|
||||
}
|
||||
|
||||
/// A run's output: all of it to a file, its first [`RUN_PREVIEW_MAX_LENGTH`] characters
|
||||
/// in memory (then [`RUN_PREVIEW_TRUNCATED_MARKER`]).
|
||||
pub struct OutputCollector {
|
||||
pub path: PathBuf,
|
||||
preview: String,
|
||||
preview_chars: usize,
|
||||
pub truncated: bool,
|
||||
file: Option<std::fs::File>,
|
||||
}
|
||||
|
||||
impl OutputCollector {
|
||||
pub fn create(name: &str) -> IdeResult<Self> {
|
||||
let (path, file) = temp_output_file(&format!("run-{name}"))?;
|
||||
Ok(Self { path, preview: String::new(), preview_chars: 0, truncated: false, file: Some(file) })
|
||||
}
|
||||
|
||||
pub fn append(&mut self, text: &str) {
|
||||
// A failed write keeps the preview going; the file is then incomplete.
|
||||
if let Some(f) = &mut self.file
|
||||
&& f.write_all(text.as_bytes()).is_err()
|
||||
{
|
||||
self.file = None;
|
||||
}
|
||||
if self.truncated {
|
||||
return;
|
||||
}
|
||||
// As JetBrains: reaching the limit marks the preview truncated, even without more output.
|
||||
let room = RUN_PREVIEW_MAX_LENGTH - self.preview_chars;
|
||||
let chars = text.chars().count();
|
||||
if chars < room {
|
||||
self.preview_chars += chars;
|
||||
self.preview.push_str(text);
|
||||
} else {
|
||||
let cut = text.char_indices().nth(room).map_or(text.len(), |(i, _)| i);
|
||||
self.preview.push_str(&text[..cut]);
|
||||
self.preview.push_str(RUN_PREVIEW_TRUNCATED_MARKER);
|
||||
self.preview_chars = RUN_PREVIEW_MAX_LENGTH;
|
||||
self.truncated = true;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn preview(&self) -> &str {
|
||||
&self.preview
|
||||
}
|
||||
}
|
||||
|
||||
/// Output files older than this are removed when a new one is created.
|
||||
const OUTPUT_MAX_AGE: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
pub fn temp_output_file(prefix: &str) -> IdeResult<(PathBuf, std::fs::File)> {
|
||||
let dir = output_dir()?;
|
||||
remove_old_logs(&dir, OUTPUT_MAX_AGE);
|
||||
let safe: String =
|
||||
prefix.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).take(40).collect();
|
||||
let path = dir.join(format!("{safe}-{}.log", uuid::Uuid::new_v4().simple()));
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let file = options.open(&path)?;
|
||||
Ok((path, file))
|
||||
}
|
||||
|
||||
/// Best effort: removes `*.log` files in `dir` last modified more than `max_age` ago.
|
||||
fn remove_old_logs(dir: &Path, max_age: std::time::Duration) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { return };
|
||||
let now = std::time::SystemTime::now();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_none_or(|e| e != "log") {
|
||||
continue;
|
||||
}
|
||||
let old = entry
|
||||
.metadata()
|
||||
.ok()
|
||||
.filter(std::fs::Metadata::is_file)
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|modified| now.duration_since(modified).ok())
|
||||
.is_some_and(|age| age > max_age);
|
||||
if old {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A directory only this user can read: output may hold secrets, and `/tmp` is shared on Linux.
|
||||
fn output_dir() -> IdeResult<PathBuf> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{DirBuilderExt, MetadataExt};
|
||||
let uid = rustix::process::getuid().as_raw();
|
||||
let dir = std::env::temp_dir().join(format!("openidea-mcp-{uid}"));
|
||||
match std::fs::DirBuilder::new().mode(0o700).create(&dir) {
|
||||
Ok(()) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(&dir)?;
|
||||
if !meta.is_dir() || meta.uid() != uid || meta.mode() & 0o077 != 0 {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::PermissionDenied,
|
||||
format!("{} is not a private directory of this user", dir.display()),
|
||||
));
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let dir = std::env::temp_dir().join("openidea-mcp");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn preview_line_keeps_a_fixed_length() {
|
||||
// JetBrains' `truncateRunConfigurationPreviewLine`: 1000 characters including the marker.
|
||||
let line = "a".repeat(2000);
|
||||
let cut = truncate_tail(&line, 3, 1000 - RUN_PREVIEW_TRUNCATED_MARKER.len(), RUN_PREVIEW_TRUNCATED_MARKER);
|
||||
assert!(cut.starts_with("<truncated>"));
|
||||
assert_eq!(cut.len(), 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collector_limits_the_preview_and_keeps_everything_in_the_file() {
|
||||
let mut c = OutputCollector::create("test").unwrap();
|
||||
let output = "a".repeat(RUN_PREVIEW_MAX_LENGTH + 1);
|
||||
c.append(&output[..10]);
|
||||
c.append(&output[10..]);
|
||||
assert_eq!(c.preview(), format!("{}{RUN_PREVIEW_TRUNCATED_MARKER}", &output[..RUN_PREVIEW_MAX_LENGTH]));
|
||||
assert!(c.truncated);
|
||||
assert_eq!(std::fs::read_to_string(&c.path).unwrap(), output);
|
||||
let _ = std::fs::remove_file(&c.path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removes_day_old_logs() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let old = std::time::SystemTime::now() - Duration::from_secs(25 * 60 * 60);
|
||||
for name in ["old.log", "new.log", "old.txt"] {
|
||||
let file = std::fs::File::create(dir.path().join(name)).unwrap();
|
||||
if name.starts_with("old") {
|
||||
file.set_modified(old).unwrap();
|
||||
}
|
||||
}
|
||||
remove_old_logs(dir.path(), OUTPUT_MAX_AGE);
|
||||
assert!(!dir.path().join("old.log").exists());
|
||||
assert!(dir.path().join("new.log").exists());
|
||||
assert!(dir.path().join("old.txt").exists(), "only logs are removed");
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
/// POSIX-like word splitting: whitespace separates, quotes group, `\` escapes (not inside `'…'`).
|
||||
pub fn split_words(line: &str) -> IdeResult<Vec<String>> {
|
||||
let mut words = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut in_word = false;
|
||||
let mut chars = line.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'\'' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('\'') => break,
|
||||
Some(c) => word.push(c),
|
||||
None => return Err(IdeError::invalid("unterminated ' quote")),
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' => {
|
||||
in_word = true;
|
||||
loop {
|
||||
match chars.next() {
|
||||
Some('"') => break,
|
||||
Some('\\') => match chars.next() {
|
||||
Some(c @ ('"' | '\\' | '$' | '`')) => word.push(c),
|
||||
Some(c) => {
|
||||
word.push('\\');
|
||||
word.push(c);
|
||||
}
|
||||
None => return Err(IdeError::invalid("unterminated \" quote")),
|
||||
},
|
||||
Some(c) => word.push(c),
|
||||
None => return Err(IdeError::invalid("unterminated \" quote")),
|
||||
}
|
||||
}
|
||||
}
|
||||
'\\' if !cfg!(windows) => {
|
||||
in_word = true;
|
||||
if let Some(c) = chars.next() {
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
c if c.is_whitespace() => {
|
||||
if in_word {
|
||||
words.push(std::mem::take(&mut word));
|
||||
in_word = false;
|
||||
}
|
||||
}
|
||||
c => {
|
||||
in_word = true;
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_word {
|
||||
words.push(word);
|
||||
}
|
||||
Ok(words)
|
||||
}
|
||||
|
||||
pub fn quote(arg: &str) -> String {
|
||||
let plain = !arg.is_empty() && arg.chars().all(|c| c.is_ascii_alphanumeric() || "-_./=:@%+,".contains(c));
|
||||
if plain { arg.to_owned() } else { format!("'{}'", arg.replace('\'', "'\\''")) }
|
||||
}
|
||||
@@ -1,975 +0,0 @@
|
||||
//! Run points inside a file (the editor's gutter ▶ icons) and the run configuration of
|
||||
//! one: a port of the frontend's `features/run/targets.ts` and `context.ts` for Rust,
|
||||
//! JavaScript/TypeScript, Python and Go. JVM files are not covered (the editor scans
|
||||
//! them with a parser of its own).
|
||||
|
||||
use ide_run::RunConfig;
|
||||
use regex_lite::Regex;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
/// Above this size files are not scanned (generated / minified files).
|
||||
const MAX_SCAN: usize = 400_000;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Main,
|
||||
Test,
|
||||
Suite,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Target {
|
||||
/// 0-based line of the declaration.
|
||||
pub line: usize,
|
||||
pub kind: Kind,
|
||||
/// Display name (`adds_numbers`, `math > adds`).
|
||||
pub name: String,
|
||||
/// Rust module path, describe chain, Python class chain…
|
||||
pub path: Vec<String>,
|
||||
}
|
||||
|
||||
macro_rules! re {
|
||||
($name:ident, $re:expr) => {
|
||||
static $name: LazyLock<Regex> = LazyLock::new(|| Regex::new($re).expect("valid regex"));
|
||||
};
|
||||
}
|
||||
|
||||
fn indent_of(s: &str) -> usize {
|
||||
s.chars().take_while(|c| c.is_whitespace()).map(|c| if c == '\t' { 4 } else { 1 }).sum()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Rust
|
||||
|
||||
re!(LINE_COMMENT, r"//.*$");
|
||||
re!(STRING, r#""(?:[^"\\]|\\.)*""#);
|
||||
re!(CHAR, r"'(?:[^'\\]|\\.)'");
|
||||
|
||||
/// Strips string literals and line comments (rough) so braces inside them don't count.
|
||||
fn code_only(line: &str) -> String {
|
||||
let line = LINE_COMMENT.replace(line, "");
|
||||
let line = STRING.replace_all(&line, "\"\"");
|
||||
CHAR.replace_all(&line, "''").into_owned()
|
||||
}
|
||||
|
||||
re!(RUST_SRC, r"(?:^|/)src/(.+)\.rs$");
|
||||
|
||||
fn rust_module_prefix(rel: &str) -> Vec<String> {
|
||||
let Some(m) = RUST_SRC.captures(rel) else { return Vec::new() };
|
||||
let mut parts: Vec<String> = m[1].split('/').map(str::to_owned).collect();
|
||||
let last = parts.last().cloned().unwrap_or_default();
|
||||
if parts.len() == 1 && (last == "lib" || last == "main") || parts[0] == "bin" {
|
||||
return Vec::new();
|
||||
}
|
||||
if last == "mod" {
|
||||
parts.pop();
|
||||
}
|
||||
parts
|
||||
}
|
||||
|
||||
re!(RUST_TEST_ATTR, r"^#\[(?:[\w:]+::)?test\b|^#\[(?:tokio|async_std|actix_rt|rstest|test_case)(?:::\w+)?\b");
|
||||
re!(RUST_CFG_TEST, r"^#\[cfg\(test\)\]");
|
||||
re!(RUST_FN, r"^(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+(\w+)");
|
||||
re!(RUST_MOD, r"^(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*\{");
|
||||
re!(RUST_MAIN_FILE, r"(?:^|/)(?:src/main|src/bin/[^/]+|examples/[^/]+)\.rs$");
|
||||
|
||||
fn rust_targets(lines: &[&str], rel: &str) -> Vec<Target> {
|
||||
let mut out: Vec<Target> = Vec::new();
|
||||
let prefix = rust_module_prefix(rel);
|
||||
let mut mods: Vec<(String, i64)> = Vec::new();
|
||||
let mut depth: i64 = 0;
|
||||
let mut pending_test = false;
|
||||
let mut pending_cfg_test = false;
|
||||
for (i, raw) in lines.iter().enumerate() {
|
||||
let line = code_only(raw);
|
||||
let trimmed = line.trim();
|
||||
if RUST_TEST_ATTR.is_match(trimmed) {
|
||||
pending_test = true;
|
||||
}
|
||||
if RUST_CFG_TEST.is_match(trimmed) {
|
||||
pending_cfg_test = true;
|
||||
}
|
||||
let mod_path = || prefix.iter().cloned().chain(mods.iter().map(|m| m.0.clone())).collect::<Vec<_>>();
|
||||
if let Some(f) = RUST_FN.captures(trimmed) {
|
||||
let name = f[1].to_owned();
|
||||
if pending_test {
|
||||
let mut path = mod_path();
|
||||
path.push(name.clone());
|
||||
out.push(Target { line: i, kind: Kind::Test, name, path });
|
||||
} else if name == "main" && depth == 0 && RUST_MAIN_FILE.is_match(rel) {
|
||||
out.push(Target { line: i, kind: Kind::Main, name, path: Vec::new() });
|
||||
}
|
||||
pending_test = false;
|
||||
pending_cfg_test = false;
|
||||
} else if let Some(m) = RUST_MOD.captures(trimmed) {
|
||||
let name = m[1].to_owned();
|
||||
let mut path = mod_path();
|
||||
path.push(name.clone());
|
||||
if pending_cfg_test || name == "tests" {
|
||||
out.push(Target { line: i, kind: Kind::Suite, name: name.clone(), path });
|
||||
}
|
||||
mods.push((name, depth));
|
||||
pending_test = false;
|
||||
pending_cfg_test = false;
|
||||
} else if !trimmed.is_empty() && !trimmed.starts_with("#[") && !trimmed.starts_with("//") {
|
||||
pending_test = false;
|
||||
if !trimmed.starts_with('#') {
|
||||
pending_cfg_test = false;
|
||||
}
|
||||
}
|
||||
for ch in line.chars() {
|
||||
if ch == '{' {
|
||||
depth += 1;
|
||||
} else if ch == '}' {
|
||||
depth -= 1;
|
||||
while mods.last().is_some_and(|m| m.1 >= depth) {
|
||||
mods.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A suite that contains no tests is noise.
|
||||
let tests: Vec<Vec<String>> = out.iter().filter(|t| t.kind == Kind::Test).map(|t| t.path.clone()).collect();
|
||||
out.retain(|t| t.kind != Kind::Suite || tests.iter().any(|p| p.len() > t.path.len() && p.starts_with(&t.path)));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- JavaScript / TypeScript
|
||||
|
||||
re!(
|
||||
JS_CALL,
|
||||
r"^(\s*)(describe|suite|context|test|it)\b(?:\.(?:only|skip|todo|concurrent|failing|sequential))*(\.each\s*(?:\(.*?\)|`[^`]*`))?\s*\(\s*"
|
||||
);
|
||||
|
||||
/// The string literal at the start of `s` (quote, then text up to the same unescaped quote).
|
||||
fn js_string(s: &str) -> Option<(char, String)> {
|
||||
let mut chars = s.chars();
|
||||
let quote = chars.next().filter(|q| matches!(q, '\'' | '"' | '`'))?;
|
||||
let mut raw = String::new();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == quote {
|
||||
return Some((quote, raw));
|
||||
}
|
||||
raw.push(c);
|
||||
if c == '\\' {
|
||||
raw.push(chars.next()?);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
re!(JS_ESCAPE, r"\\(.)");
|
||||
|
||||
fn js_targets(lines: &[&str]) -> Vec<Target> {
|
||||
let mut out = Vec::new();
|
||||
let mut stack: Vec<(usize, String)> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
let Some(m) = JS_CALL.captures(line) else { continue };
|
||||
let Some((_quote, raw)) = js_string(&line[m.get(0).map_or(0, |g| g.end())..]) else { continue };
|
||||
let indent = indent_of(&m[1]);
|
||||
while stack.last().is_some_and(|s| s.0 >= indent) {
|
||||
stack.pop();
|
||||
}
|
||||
let name = JS_ESCAPE.replace_all(&raw, "$1").into_owned();
|
||||
let mut path: Vec<String> = stack.iter().map(|s| s.1.clone()).collect();
|
||||
path.push(name.clone());
|
||||
let suite = matches!(&m[2], "describe" | "suite" | "context");
|
||||
out.push(Target { line: i, kind: if suite { Kind::Suite } else { Kind::Test }, name: path.join(" > "), path });
|
||||
if suite {
|
||||
stack.push((indent, name));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_regexp(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if ".*+?^${}()|[]\\".contains(c) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
re!(JS_PATTERN_PLACEHOLDER, r"%[sdifjo#p]|\\\$\\\{[^}]*\\\}|\$\w+");
|
||||
|
||||
/// Jest / Vitest `-t` pattern for a test (full name = describe chain + name, space separated).
|
||||
fn js_name_pattern(target: &Target) -> String {
|
||||
let pattern = escape_regexp(&target.path.join(" "));
|
||||
// `test.each` placeholders (%s, $name, ${…}) match anything.
|
||||
let pattern = JS_PATTERN_PLACEHOLDER.replace_all(&pattern, ".*");
|
||||
if target.kind == Kind::Suite { format!("^{pattern}") } else { format!("^{pattern}$") }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Python
|
||||
|
||||
re!(PY_COMMENT, r"^\s*#");
|
||||
re!(PY_CLASS, r"^\s*class\s+(\w+)");
|
||||
re!(PY_TEST_DEF, r"^\s*(?:async\s+)?def\s+(test\w*)\s*\(");
|
||||
re!(PY_MAIN, r#"^if\s+__name__\s*==\s*['"]__main__['"]\s*:"#);
|
||||
|
||||
fn python_targets(lines: &[&str]) -> Vec<Target> {
|
||||
let mut out = Vec::new();
|
||||
let mut classes: Vec<(usize, String, bool)> = Vec::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
|
||||
continue;
|
||||
}
|
||||
let indent = indent_of(line);
|
||||
while classes.last().is_some_and(|c| c.0 >= indent) {
|
||||
classes.pop();
|
||||
}
|
||||
if let Some(c) = PY_CLASS.captures(line) {
|
||||
let test = c[1].starts_with("Test") && classes.iter().all(|c| c.2);
|
||||
classes.push((indent, c[1].to_owned(), test));
|
||||
if test {
|
||||
let path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
|
||||
out.push(Target { line: i, kind: Kind::Suite, name: c[1].to_owned(), path });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(d) = PY_TEST_DEF.captures(line)
|
||||
&& classes.iter().all(|c| c.2)
|
||||
{
|
||||
let mut path: Vec<String> = classes.iter().map(|c| c.1.clone()).collect();
|
||||
path.push(d[1].to_owned());
|
||||
out.push(Target { line: i, kind: Kind::Test, name: path.join("."), path });
|
||||
continue;
|
||||
}
|
||||
if indent == 0 && PY_MAIN.is_match(line) {
|
||||
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- Go
|
||||
|
||||
re!(GO_PACKAGE_MAIN, r"^package\s+main\b");
|
||||
re!(GO_TEST, r"^func\s+(Test\w*|Benchmark\w*|Example\w*|Fuzz\w*)\s*\(\s*(?:\w+\s+\*testing\.[TFB])?\s*\)?");
|
||||
re!(GO_TESTING_ARG, r"\*testing\.[TFB]");
|
||||
re!(GO_TESTING_TB, r"\*testing\.[TB]\b");
|
||||
re!(GO_MAIN, r"^func\s+main\s*\(\s*\)");
|
||||
re!(GO_ONE_LINE, r"\}\s*(?://.*)?$");
|
||||
re!(GO_RUN_LITERAL, r#"\b\w+\.Run\(\s*"((?:[^"\\]|\\.)*)"\s*,"#);
|
||||
re!(GO_RUN_FIELD, r"\b\w+\.Run\(\s*\w+\.(\w+)\s*,");
|
||||
re!(GO_RUN_KEY, r"\b\w+\.Run\(\s*(\w+)\s*,");
|
||||
re!(GO_MAP_KEY, r#"^\s*"((?:[^"\\]|\\.)*)"\s*:\s*\{"#);
|
||||
re!(WHITESPACE, r"\s");
|
||||
|
||||
fn go_targets(lines: &[&str]) -> Vec<Target> {
|
||||
let mut out = Vec::new();
|
||||
let is_main = lines.iter().any(|l| GO_PACKAGE_MAIN.is_match(l));
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if let Some(t) = GO_TEST.captures(line) {
|
||||
let name = t[1].to_owned();
|
||||
if name.starts_with("Example") || GO_TESTING_ARG.is_match(line) {
|
||||
out.push(Target { line: i, kind: Kind::Test, name: name.clone(), path: vec![name.clone()] });
|
||||
if GO_TESTING_TB.is_match(line) {
|
||||
out.extend(go_subtests(lines, i, &name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if is_main && GO_MAIN.is_match(line) {
|
||||
out.push(Target { line: i, kind: Kind::Main, name: "main".into(), path: Vec::new() });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Subtests of the test function at `start`: `t.Run("name", …)`, and table entries run with
|
||||
/// `t.Run(tt.name, …)` (`{name: "…", …}`) or keyed by name (`"…": {…}`).
|
||||
fn go_subtests(lines: &[&str], start: usize, test: &str) -> Vec<Target> {
|
||||
if GO_ONE_LINE.is_match(lines[start]) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut end = start;
|
||||
while end + 1 < lines.len() && !lines[end].starts_with('}') {
|
||||
end += 1;
|
||||
}
|
||||
let body = &lines[start + 1..end.max(start + 1)];
|
||||
let mut out: Vec<Target> = Vec::new();
|
||||
let mut add = |offset: usize, name: &str| {
|
||||
let sub = WHITESPACE.replace_all(&JS_ESCAPE.replace_all(name, "$1"), "_").into_owned();
|
||||
if !out.iter().any(|t| t.path[1] == sub) {
|
||||
out.push(Target {
|
||||
line: start + 1 + offset,
|
||||
kind: Kind::Test,
|
||||
name: format!("{test}/{sub}"),
|
||||
path: vec![test.to_owned(), sub],
|
||||
});
|
||||
}
|
||||
};
|
||||
let mut fields: Vec<String> = Vec::new();
|
||||
let mut keys: Vec<String> = Vec::new();
|
||||
for (i, l) in body.iter().enumerate() {
|
||||
if let Some(m) = GO_RUN_LITERAL.captures(l) {
|
||||
add(i, &m[1]);
|
||||
}
|
||||
if let Some(m) = GO_RUN_FIELD.captures(l) {
|
||||
fields.push(m[1].to_owned());
|
||||
}
|
||||
if let Some(m) = GO_RUN_KEY.captures(l) {
|
||||
let range = Regex::new(&format!(r"\bfor\s+{}\s*,\s*\w+\s*:=\s*range\b", &m[1])).expect("valid regex");
|
||||
if body[..i].iter().any(|b| range.is_match(b)) {
|
||||
keys.push(m[1].to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
if !fields.is_empty() || !keys.is_empty() {
|
||||
let field_res: Vec<Regex> = fields
|
||||
.iter()
|
||||
.map(|f| Regex::new(&format!(r#"\b{f}\s*:\s*"((?:[^"\\]|\\.)*)""#)).expect("valid regex"))
|
||||
.collect();
|
||||
for (i, l) in body.iter().enumerate() {
|
||||
for re in &field_res {
|
||||
if let Some(m) = re.captures(l) {
|
||||
add(i, &m[1]);
|
||||
}
|
||||
}
|
||||
if !keys.is_empty()
|
||||
&& let Some(m) = GO_MAP_KEY.captures(l)
|
||||
{
|
||||
add(i, &m[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|t| t.line);
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- detection
|
||||
|
||||
re!(JS_FILE, r"\.[cm]?[jt]sx?$");
|
||||
re!(JS_TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/");
|
||||
re!(PY_TEST_FILE, r"(^|/)(test_[^/]*|[^/]*_test)\.py$|(^|/)tests?/");
|
||||
|
||||
/// Run targets of a file (`rel`: project-relative with `/`, picks the language).
|
||||
pub fn detect(text: &str, rel: &str) -> Vec<Target> {
|
||||
if text.len() > MAX_SCAN {
|
||||
return Vec::new();
|
||||
}
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
if rel.ends_with(".rs") {
|
||||
rust_targets(&lines, rel)
|
||||
} else if JS_FILE.is_match(rel) {
|
||||
if JS_TEST_FILE.is_match(rel) { js_targets(&lines) } else { Vec::new() }
|
||||
} else if rel.ends_with(".py") {
|
||||
let test_file = PY_TEST_FILE.is_match(rel);
|
||||
python_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
|
||||
} else if rel.ends_with(".go") {
|
||||
let test_file = rel.ends_with("_test.go");
|
||||
go_targets(&lines).into_iter().filter(|t| t.kind == Kind::Main || test_file).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The last line (0-based) of the block declared at `start`: by indentation in Python, else
|
||||
/// where the braces opened on (or, for a Rust signature, after) the declaration close again.
|
||||
fn block_end(lines: &[&str], start: usize, rel: &str) -> usize {
|
||||
if rel.ends_with(".py") {
|
||||
let indent = indent_of(lines[start]);
|
||||
let mut end = start;
|
||||
for (i, line) in lines.iter().enumerate().skip(start + 1) {
|
||||
if line.trim().is_empty() || PY_COMMENT.is_match(line) {
|
||||
continue;
|
||||
}
|
||||
if indent_of(line) <= indent {
|
||||
break;
|
||||
}
|
||||
end = i;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
let rust = rel.ends_with(".rs");
|
||||
let mut depth: i64 = 0;
|
||||
let mut opened = false;
|
||||
for (i, raw) in lines.iter().enumerate().skip(start) {
|
||||
let line = code_only(raw);
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'{' => {
|
||||
depth += 1;
|
||||
opened = true;
|
||||
}
|
||||
'}' => depth -= 1,
|
||||
// A Rust declaration without a body (`fn f();`).
|
||||
';' if rust && !opened => return i,
|
||||
_ => {}
|
||||
}
|
||||
if opened && depth <= 0 {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
// Only a Rust signature continues on the next lines; a call without a block ends here.
|
||||
if !opened && !rust {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
lines.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// The run targets whose declaration or body contains `line` (0-based), innermost first.
|
||||
pub fn enclosing(text: &str, rel: &str, line: usize) -> Vec<Target> {
|
||||
let lines: Vec<&str> = text.split('\n').map(|l| l.strip_suffix('\r').unwrap_or(l)).collect();
|
||||
let mut out: Vec<Target> =
|
||||
detect(text, rel).into_iter().filter(|t| t.line <= line && line <= block_end(&lines, t.line, rel)).collect();
|
||||
out.sort_by_key(|t| std::cmp::Reverse(t.line));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- configurations
|
||||
|
||||
/// A configuration's working directory relative to the project root, with `/` (Go: its module).
|
||||
fn cwd_of(root: &Path, c: &RunConfig) -> String {
|
||||
let module = (c.kind == "go").then(|| c.extras.params.get("module").cloned()).flatten();
|
||||
let cwd = module
|
||||
.or_else(|| c.cwd.as_deref().map(|p| p.strip_prefix(root).unwrap_or(p).to_string_lossy().replace('\\', "/")));
|
||||
let cwd = cwd.unwrap_or_default();
|
||||
let cwd = cwd.strip_prefix("./").or_else(|| cwd.strip_prefix('.')).unwrap_or(&cwd);
|
||||
cwd.trim_end_matches('/').to_owned()
|
||||
}
|
||||
|
||||
/// Configs whose working directory most specifically contains `rel` (ties in their original order).
|
||||
fn all_closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Vec<&'a RunConfig> {
|
||||
let score = |c: &RunConfig| -> Option<usize> {
|
||||
let cwd = cwd_of(root, c);
|
||||
if cwd.is_empty() { Some(0) } else { (rel == cwd || rel.starts_with(&format!("{cwd}/"))).then_some(cwd.len()) }
|
||||
};
|
||||
let scored: Vec<(&RunConfig, usize)> = configs.iter().filter_map(|c| score(c).map(|s| (*c, s))).collect();
|
||||
let best = scored.iter().map(|s| s.1).max();
|
||||
scored.into_iter().filter(|s| Some(s.1) == best).map(|s| s.0).collect()
|
||||
}
|
||||
|
||||
fn closest<'a>(root: &Path, configs: &[&'a RunConfig], rel: &str) -> Option<&'a RunConfig> {
|
||||
all_closest(root, configs, rel).into_iter().next()
|
||||
}
|
||||
|
||||
fn rel_to_cwd(root: &Path, rel: &str, c: &RunConfig) -> String {
|
||||
let cwd = cwd_of(root, c);
|
||||
match rel.strip_prefix(&format!("{cwd}/")) {
|
||||
Some(inner) if !cwd.is_empty() => inner.to_owned(),
|
||||
_ => rel.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_name(rel: &str) -> &str {
|
||||
rel.rsplit('/').next().unwrap_or(rel)
|
||||
}
|
||||
|
||||
fn dir_of(p: &str) -> &str {
|
||||
p.rfind('/').map_or("", |i| &p[..i])
|
||||
}
|
||||
|
||||
fn derived(base: &RunConfig, rel: &str, args: Vec<String>) -> RunConfig {
|
||||
RunConfig {
|
||||
id: format!("context:{}:{rel}", base.id),
|
||||
name: file_name(rel).to_owned(),
|
||||
args,
|
||||
detected: true,
|
||||
..base.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Kinds whose test configs can run a file of this extension.
|
||||
fn test_kinds(rel: &str) -> &'static [&'static str] {
|
||||
if JS_FILE.is_match(rel) {
|
||||
&["npm", "deno", "bun"]
|
||||
} else if rel.ends_with(".py") {
|
||||
&["pytest", "python"]
|
||||
} else if rel.ends_with(".go") {
|
||||
&["go"]
|
||||
} else if rel.ends_with(".rs") {
|
||||
&["cargo"]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's Python interpreter as `(command, leading args)`, from detected configs.
|
||||
fn project_python(configs: &[RunConfig]) -> Option<(String, Vec<String>)> {
|
||||
if let Some(py) = configs.iter().find(|c| c.kind == "python") {
|
||||
let prefix = match py.args.iter().position(|a| a == "-m") {
|
||||
Some(m) => py.args[..m].to_vec(),
|
||||
None => py.args.iter().filter(|a| !a.ends_with(".py")).cloned().collect(),
|
||||
};
|
||||
return Some((py.command.clone(), prefix));
|
||||
}
|
||||
let pytest = configs.iter().find(|c| c.kind == "pytest")?;
|
||||
static VENV: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^(.*[/\\](?:bin|Scripts)[/\\])pytest(\.exe)?$").expect("valid regex"));
|
||||
if let Some(m) = VENV.captures(&pytest.command) {
|
||||
return Some((format!("{}python{}", &m[1], m.get(2).map_or("", |g| g.as_str())), Vec::new()));
|
||||
}
|
||||
if let Some(m) = pytest.args.iter().position(|a| a == "-m")
|
||||
&& pytest.args.get(m + 1).is_some_and(|a| a == "pytest")
|
||||
{
|
||||
return Some((pytest.command.clone(), pytest.args[..m].to_vec()));
|
||||
}
|
||||
if pytest.args.first().is_some_and(|a| a == "run") && pytest.args.get(1).is_some_and(|a| a == "pytest") {
|
||||
return Some((pytest.command.clone(), vec!["run".into(), "python".into()]));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn blank(id: String, name: &str, kind: &str, command: String, args: Vec<String>) -> RunConfig {
|
||||
RunConfig {
|
||||
id,
|
||||
name: name.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
command,
|
||||
args,
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: true,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// PyCharm runs pytest for a test file even when the project declares no test setup.
|
||||
fn implicit_pytest(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
|
||||
if !rel.ends_with(".py") {
|
||||
return None;
|
||||
}
|
||||
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
|
||||
args.extend(["-m".to_owned(), "pytest".to_owned()]);
|
||||
let mut c = blank("pytest:implicit".into(), "pytest", "pytest", command, args);
|
||||
c.is_test = true;
|
||||
c.debug_adapter = Some("debugpy".into());
|
||||
Some(c)
|
||||
}
|
||||
|
||||
fn test_base_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
|
||||
let kinds = test_kinds(rel);
|
||||
// Configurations produced from a test or file are results of narrowing, never bases for it.
|
||||
let candidates: Vec<&RunConfig> = configs
|
||||
.iter()
|
||||
.filter(|c| c.is_test && kinds.contains(&c.kind.as_str()) && !c.id.starts_with("context:"))
|
||||
.collect();
|
||||
closest(root, &candidates, rel).or(candidates.first().copied()).cloned().or_else(|| implicit_pytest(rel, configs))
|
||||
}
|
||||
|
||||
/// Go test flags may come before or after package targets; binary arguments start at `-args`.
|
||||
fn go_test_parts(args: &[String]) -> (Vec<String>, Vec<String>, Vec<String>) {
|
||||
const BOOLEAN_FLAGS: &[&str] = &[
|
||||
"-a",
|
||||
"-n",
|
||||
"-race",
|
||||
"-msan",
|
||||
"-asan",
|
||||
"-cover",
|
||||
"-linkshared",
|
||||
"-modcacherw",
|
||||
"-trimpath",
|
||||
"-v",
|
||||
"-work",
|
||||
"-x",
|
||||
"-json",
|
||||
"-c",
|
||||
"-i",
|
||||
"-short",
|
||||
"-failfast",
|
||||
"-benchmem",
|
||||
"-fullpath",
|
||||
"-buildvcs",
|
||||
];
|
||||
let (mut flags, mut targets, mut program) = (Vec::new(), Vec::new(), Vec::new());
|
||||
let mut i = 1;
|
||||
while i < args.len() {
|
||||
let arg = &args[i];
|
||||
if arg == "-args" || arg == "--" {
|
||||
program = args[i..].to_vec();
|
||||
break;
|
||||
}
|
||||
if arg.starts_with('-') {
|
||||
flags.push(arg.clone());
|
||||
if !arg.contains('=') && !BOOLEAN_FLAGS.contains(&arg.as_str()) && i + 1 < args.len() {
|
||||
i += 1;
|
||||
flags.push(args[i].clone());
|
||||
}
|
||||
} else {
|
||||
targets.push(arg.clone());
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
(flags, targets, program)
|
||||
}
|
||||
|
||||
fn without_go_selection(flags: &[String]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < flags.len() {
|
||||
let f = flags[i].as_str();
|
||||
if matches!(f, "-run" | "-skip" | "-bench" | "-fuzz") {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if ["-run=", "-skip=", "-bench=", "-fuzz="].iter().any(|p| f.starts_with(p)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
out.push(flags[i].clone());
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// File-level test configuration for `rel` (the closest matching test config narrowed to the file).
|
||||
fn test_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
|
||||
let base = test_base_for(root, rel, configs)?;
|
||||
let file = rel_to_cwd(root, rel, &base);
|
||||
let with = |extra: &[String]| [base.args.clone(), extra.to_vec()].concat();
|
||||
Some(match base.kind.as_str() {
|
||||
// `npm run test -- file` works for npm, pnpm, yarn and bun.
|
||||
"npm" | "bun" if base.args.iter().any(|a| a == "--") => derived(&base, rel, with(&[file])),
|
||||
"npm" | "bun" => derived(&base, rel, with(&["--".into(), file])),
|
||||
"deno" | "pytest" | "python" => derived(&base, rel, with(&[file])),
|
||||
"go" => {
|
||||
let dir = dir_of(&file);
|
||||
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
|
||||
let (flags, _, program) = go_test_parts(&base.args);
|
||||
let args = [vec!["test".to_owned()], flags, vec![target.clone()], program].concat();
|
||||
let mut c = derived(&base, rel, args);
|
||||
c.name = format!("go test {target}");
|
||||
if c.extras.params.contains_key("goKind") {
|
||||
c.extras.params.insert("scope".into(), "directory".into());
|
||||
c.extras.params.insert("target".into(), target);
|
||||
}
|
||||
c
|
||||
}
|
||||
"cargo" => {
|
||||
static TESTS: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"(?:^|/)tests/([^/]+)\.rs$").expect("valid regex"));
|
||||
match TESTS.captures(rel) {
|
||||
Some(m) => {
|
||||
// Cargo's own options go before the test binary's `--`.
|
||||
let at = base.args.iter().position(|a| a == "--").unwrap_or(base.args.len());
|
||||
let mut args = base.args.clone();
|
||||
args.splice(at..at, ["--test".to_owned(), m[1].to_owned()]);
|
||||
derived(&base, rel, args)
|
||||
}
|
||||
None => base,
|
||||
}
|
||||
}
|
||||
_ => base,
|
||||
})
|
||||
}
|
||||
|
||||
fn main_config_for(root: &Path, rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
|
||||
if rel.ends_with(".rs") {
|
||||
static BIN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"src/bin/([^/]+)\.rs$").expect("valid regex"));
|
||||
let bin = BIN.captures(rel).map(|m| m[1].to_owned());
|
||||
let runs: Vec<&RunConfig> =
|
||||
configs.iter().filter(|c| c.kind == "cargo" && c.id.starts_with("cargo:run")).collect();
|
||||
let by_bin =
|
||||
bin.and_then(|b| runs.iter().find(|c| c.id == format!("cargo:run:{b}") || c.args.contains(&b)).copied());
|
||||
return by_bin.or_else(|| closest(root, &runs, rel)).or(runs.first().copied()).cloned();
|
||||
}
|
||||
if rel.ends_with(".go") {
|
||||
let gos: Vec<&RunConfig> = configs.iter().filter(|c| c.kind == "go").collect();
|
||||
let runs: Vec<&RunConfig> =
|
||||
gos.iter().filter(|c| c.args.first().is_some_and(|a| a == "run")).copied().collect();
|
||||
// The module containing the file (nested modules run from their own directory).
|
||||
let base = closest(root, &runs, rel)
|
||||
.or_else(|| closest(root, &gos, rel))
|
||||
.or(runs.first().copied())
|
||||
.or(gos.first().copied())?;
|
||||
let dir = rel_to_cwd(root, rel, base);
|
||||
let dir = dir_of(&dir);
|
||||
let target = if dir.is_empty() { ".".to_owned() } else { format!("./{dir}") };
|
||||
if let Some(hit) = runs.iter().find(|c| c.cwd == base.cwd && c.args.get(1) == Some(&target)) {
|
||||
return Some((*hit).clone());
|
||||
}
|
||||
let mut c = derived(base, rel, vec!["run".into(), target.clone()]);
|
||||
c.name = format!("go run {target}");
|
||||
c.is_test = false;
|
||||
return Some(c);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn script_config_for(rel: &str, configs: &[RunConfig]) -> Option<RunConfig> {
|
||||
let id = format!("context:file:{rel}");
|
||||
let name = file_name(rel);
|
||||
let script = |command: &str, args: Vec<String>| blank(id.clone(), name, "shell", command.to_owned(), args);
|
||||
if rel.ends_with(".py") {
|
||||
// Reuse the project's interpreter (venv / uv / poetry) when detected.
|
||||
let (command, mut args) = project_python(configs).unwrap_or_else(|| ("python3".into(), Vec::new()));
|
||||
args.push(rel.to_owned());
|
||||
let mut c = blank(id, name, "python", command, args);
|
||||
c.debug_adapter = Some("debugpy".into());
|
||||
return Some(c);
|
||||
}
|
||||
static JS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\.[cm]?js$").expect("valid regex"));
|
||||
if JS.is_match(rel) {
|
||||
return Some(script("node", vec![rel.to_owned()]));
|
||||
}
|
||||
if rel.ends_with(".ts") {
|
||||
return Some(script("npx", vec!["tsx".into(), rel.to_owned()]));
|
||||
}
|
||||
for (ext, shell) in [(".zsh", "zsh"), (".bash", "bash"), (".sh", "sh")] {
|
||||
if rel.ends_with(ext) {
|
||||
return Some(script(shell, vec![rel.to_owned()]));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
re!(TEST_FILE, r"\.(test|spec)\.[cm]?[jt]sx?$|(^|/)__tests__/|(^|/)(test_[^/]*\.py|[^/]*_test\.py|[^/]*_test\.go)$");
|
||||
re!(RUST_TESTS_FILE, r"(^|/)tests/[^/]+\.rs$");
|
||||
re!(RUST_MAIN_ROLE, r"(^|/)src/main\.rs$|(^|/)src/bin/[^/]+\.rs$");
|
||||
re!(GO_PACKAGE_MAIN_MULTILINE, r"(?m)^\s*package\s+main\b");
|
||||
re!(SCRIPT_FILE, r"\.(py|sh|bash|zsh|[cm]?js|ts)$");
|
||||
|
||||
/// "Run Context Configuration" for the whole file: its tests, its main target or the script.
|
||||
pub fn context_config(root: &Path, rel: &str, configs: &[RunConfig], content: &str) -> Option<RunConfig> {
|
||||
let name = file_name(rel);
|
||||
if TEST_FILE.is_match(rel) || (RUST_TESTS_FILE.is_match(rel) && name != "mod.rs") {
|
||||
return test_config_for(root, rel, configs);
|
||||
}
|
||||
if RUST_MAIN_ROLE.is_match(rel) || (rel.ends_with(".go") && GO_PACKAGE_MAIN_MULTILINE.is_match(content)) {
|
||||
return main_config_for(root, rel, configs).or_else(|| script_config_for(rel, configs));
|
||||
}
|
||||
if SCRIPT_FILE.is_match(name) {
|
||||
return script_config_for(rel, configs);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn replace_last(mut args: Vec<String>, f: impl FnOnce(&str) -> String) -> Vec<String> {
|
||||
if let Some(last) = args.last_mut() {
|
||||
*last = f(last);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// The run configuration of one target, or `None` when the project has no matching runner.
|
||||
pub fn target_config(
|
||||
root: &Path,
|
||||
target: &Target,
|
||||
rel: &str,
|
||||
configs: &[RunConfig],
|
||||
content: &str,
|
||||
) -> Option<RunConfig> {
|
||||
if target.kind == Kind::Main {
|
||||
return context_config(root, rel, configs, content);
|
||||
}
|
||||
let file = test_config_for(root, rel, configs)?;
|
||||
let short =
|
||||
if target.kind == Kind::Suite { target.path.last().cloned().unwrap_or_default() } else { target.name.clone() };
|
||||
let named = |args: Vec<String>| RunConfig {
|
||||
id: format!("context:target:{}:{rel}:{}", file.id, target.path.join("/")),
|
||||
name: short.clone(),
|
||||
args,
|
||||
detected: true,
|
||||
..file.clone()
|
||||
};
|
||||
let with = |extra: Vec<String>| [file.args.clone(), extra].concat();
|
||||
match file.kind.as_str() {
|
||||
"npm" | "bun" | "deno" => Some(named(with(vec!["-t".into(), js_name_pattern(target)]))),
|
||||
"pytest" | "python" => {
|
||||
Some(named(replace_last(file.args.clone(), |f| format!("{f}::{}", target.path.join("::")))))
|
||||
}
|
||||
"go" => {
|
||||
let pattern = format!("^{}$", target.path.iter().map(|p| escape_regexp(p)).collect::<Vec<_>>().join("$/^"));
|
||||
let benchmark = target.path.first().is_some_and(|p| p.starts_with("Benchmark"));
|
||||
let (flags, targets, program) = go_test_parts(&file.args);
|
||||
let selection: Vec<String> = if benchmark {
|
||||
vec!["-run".into(), "^$".into(), "-bench".into(), pattern.clone()]
|
||||
} else {
|
||||
vec!["-run".into(), pattern.clone()]
|
||||
};
|
||||
let flags = without_go_selection(&flags);
|
||||
let mut c = named([vec!["test".to_owned()], flags.clone(), targets, selection, program].concat());
|
||||
if c.extras.params.contains_key("goKind") {
|
||||
let mut tool_args: Vec<String> = flags;
|
||||
if benchmark {
|
||||
tool_args.extend(["-bench".to_owned(), pattern.clone()]);
|
||||
}
|
||||
c.extras.params.insert("pattern".into(), if benchmark { "^$".into() } else { pattern });
|
||||
c.extras.params.insert(
|
||||
"toolArgs".into(),
|
||||
tool_args.iter().map(|a| super::process::quote(a)).collect::<Vec<_>>().join(" "),
|
||||
);
|
||||
}
|
||||
Some(c)
|
||||
}
|
||||
"cargo" => {
|
||||
let path = target.path.join("::");
|
||||
// Test-binary arguments follow the one `--` (`cargo test -- --nocapture` has it already).
|
||||
let separator: Vec<String> =
|
||||
if file.args.iter().any(|a| a == "--") { Vec::new() } else { vec!["--".into()] };
|
||||
Some(named(if target.kind == Kind::Suite {
|
||||
with([separator, vec![format!("{path}::")]].concat())
|
||||
} else {
|
||||
with([separator, vec![path, "--exact".into()]].concat())
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pick(ts: &[Target]) -> Vec<(usize, Kind, String)> {
|
||||
ts.iter().map(|t| (t.line, t.kind, t.path.join("/"))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rust_main_tests_and_test_modules() {
|
||||
let src = [
|
||||
"fn main() {}",
|
||||
"#[cfg(test)]",
|
||||
"mod tests {",
|
||||
" use super::*;",
|
||||
" #[test]",
|
||||
" fn adds() { let s = \"}\"; }",
|
||||
" #[tokio::test]",
|
||||
" #[ignore]",
|
||||
" async fn async_one() {}",
|
||||
" fn helper() {}",
|
||||
"}",
|
||||
"fn after() {}",
|
||||
]
|
||||
.join("\n");
|
||||
assert_eq!(
|
||||
pick(&detect(&src, "src/main.rs")),
|
||||
[
|
||||
(0, Kind::Main, String::new()),
|
||||
(2, Kind::Suite, "tests".into()),
|
||||
(5, Kind::Test, "tests/adds".into()),
|
||||
(8, Kind::Test, "tests/async_one".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
pick(&detect("#[test]\nfn x() {}", "crates/a/src/foo/bar.rs")),
|
||||
[(1, Kind::Test, "foo/bar/x".into())]
|
||||
);
|
||||
assert!(detect("fn main() {}", "src/lib.rs").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn js_describe_chains() {
|
||||
let src = [
|
||||
"describe('math', () => {",
|
||||
" it('adds', () => {});",
|
||||
" describe(\"nested\", () => {",
|
||||
" test.each([[1]])('n=%i', () => {});",
|
||||
" });",
|
||||
" test.skip(`subs`, () => {});",
|
||||
"});",
|
||||
"const item = items.find((x) => x);",
|
||||
"test('top', () => {});",
|
||||
]
|
||||
.join("\n");
|
||||
let ts = detect(&src, "src/math.test.ts");
|
||||
assert_eq!(
|
||||
pick(&ts),
|
||||
[
|
||||
(0, Kind::Suite, "math".into()),
|
||||
(1, Kind::Test, "math/adds".into()),
|
||||
(2, Kind::Suite, "math/nested".into()),
|
||||
(3, Kind::Test, "math/nested/n=%i".into()),
|
||||
(5, Kind::Test, "math/subs".into()),
|
||||
(8, Kind::Test, "top".into()),
|
||||
]
|
||||
);
|
||||
assert_eq!(js_name_pattern(&ts[1]), "^math adds$");
|
||||
assert_eq!(js_name_pattern(&ts[3]), "^math nested n=.*$");
|
||||
assert_eq!(js_name_pattern(&ts[0]), "^math");
|
||||
assert!(detect("test('x', () => {})", "src/math.ts").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn python_and_go() {
|
||||
let src = "class TestCalc:\n def test_add(self):\n pass\ndef test_top():\n pass\nclass Helper:\n def test_no(self):\n pass\nif __name__ == '__main__':\n main()\n";
|
||||
assert_eq!(
|
||||
pick(&detect(src, "tests/test_calc.py")),
|
||||
[
|
||||
(0, Kind::Suite, "TestCalc".into()),
|
||||
(1, Kind::Test, "TestCalc/test_add".into()),
|
||||
(3, Kind::Test, "test_top".into()),
|
||||
(8, Kind::Main, String::new()),
|
||||
]
|
||||
);
|
||||
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero case\", func(t *testing.T) {})\n}\n";
|
||||
assert_eq!(
|
||||
pick(&detect(go, "calc_test.go")),
|
||||
[(2, Kind::Test, "TestAdd".into()), (3, Kind::Test, "TestAdd/zero_case".into())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrows_cargo_test_configs() {
|
||||
let root = Path::new("/p");
|
||||
let mut test = blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into()]);
|
||||
test.is_test = true;
|
||||
let configs = vec![test];
|
||||
let src = "#[cfg(test)]\nmod tests {\n #[test]\n fn adds() {}\n}\n";
|
||||
let ts = detect(src, "src/math.rs");
|
||||
let c = target_config(root, &ts[1], "src/math.rs", &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::adds", "--exact"]);
|
||||
let c = target_config(root, &ts[0], "src/math.rs", &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "--", "math::tests::"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cargo_test_args_get_one_separator() {
|
||||
let root = Path::new("/p");
|
||||
let mut test =
|
||||
blank("cargo:test".into(), "test", "cargo", "cargo".into(), vec!["test".into(), "--".into(), "-q".into()]);
|
||||
test.is_test = true;
|
||||
let configs = vec![test];
|
||||
let src = "#[test]\nfn adds() {}\n";
|
||||
let ts = detect(src, "tests/math.rs");
|
||||
let c = target_config(root, &ts[0], "tests/math.rs", &configs, src).unwrap();
|
||||
assert_eq!(c.args, ["test", "--test", "math", "--", "-q", "adds", "--exact"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_line_in_a_body_picks_the_enclosing_target() {
|
||||
let names = |src: &str, rel: &str, line: usize| -> Vec<String> {
|
||||
enclosing(src, rel, line).iter().map(|t| t.path.join("/")).collect()
|
||||
};
|
||||
let rust = [
|
||||
"#[cfg(test)]",
|
||||
"mod tests {",
|
||||
" #[test]",
|
||||
" fn adds(",
|
||||
" x: u8,",
|
||||
" ) {",
|
||||
" let s = \"}\";",
|
||||
" assert!(true);",
|
||||
" }",
|
||||
"",
|
||||
" fn helper() {}",
|
||||
"}",
|
||||
]
|
||||
.join("\n");
|
||||
assert_eq!(names(&rust, "src/math.rs", 7), ["math/tests/adds", "math/tests"]);
|
||||
assert_eq!(names(&rust, "src/math.rs", 4), ["math/tests/adds", "math/tests"]);
|
||||
assert_eq!(names(&rust, "src/math.rs", 10), ["math/tests"]);
|
||||
|
||||
let js = "describe('math', () => {\n it('adds', () => {\n expect(1).toBe(1);\n });\n it('one', fn);\n const x = 1;\n});\n";
|
||||
assert_eq!(names(js, "src/math.test.ts", 2), ["math/adds", "math"]);
|
||||
assert_eq!(names(js, "src/math.test.ts", 5), ["math"]);
|
||||
|
||||
let py = "class TestCalc:\n def test_add(self):\n x = 1\n\n assert x\n def helper(self):\n pass\n";
|
||||
assert_eq!(names(py, "tests/test_calc.py", 4), ["TestCalc/test_add", "TestCalc"]);
|
||||
assert_eq!(names(py, "tests/test_calc.py", 6), ["TestCalc"]);
|
||||
|
||||
let go = "package calc\n\nfunc TestAdd(t *testing.T) {\n\tt.Run(\"zero\", func(t *testing.T) {\n\t\tx := 0\n\t})\n\ty := 1\n}\n";
|
||||
assert_eq!(names(go, "calc_test.go", 4), ["TestAdd/zero", "TestAdd"]);
|
||||
assert_eq!(names(go, "calc_test.go", 6), ["TestAdd"]);
|
||||
assert!(names(go, "calc_test.go", 0).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,901 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (mcpserver.toolsets/src/general/SearchToolset.kt,
|
||||
// SearchSymbolSupport.kt), Apache-2.0.
|
||||
//! Text, regex, file and symbol search.
|
||||
//!
|
||||
//! Coordinates are 1-based, end exclusive; columns count UTF-16 code units like
|
||||
//! the editor (one per character except emoji and other astral characters).
|
||||
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use crate::mcp::util::{INDEXING_PARTIAL_RESULT_REASON, MEDIUM_TIMEOUT};
|
||||
use crate::ops::search::SearchEvent;
|
||||
use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, Location, Range};
|
||||
use ide_search::{FileMask, FileMatches, SearchQuery, SearchScope, SearchSummary};
|
||||
use ide_vfs::EntryKind;
|
||||
use parking_lot::Mutex;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::cell::Cell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub const MAX_RESULTS_UPPER_BOUND: usize = 5000;
|
||||
const DEFAULT_LIMIT: i64 = 1000;
|
||||
pub const SYMBOL_SEARCH_DEFAULT_LIMIT: i64 = 50;
|
||||
pub const SEARCH_TIMEOUT_PARTIAL_RESULT_REASON: &str =
|
||||
"The search timed out after 10 s. Narrow the query or pass paths.";
|
||||
/// Symbols requested from the index per result when `paths` filters them afterwards.
|
||||
const SYMBOL_POOL_FACTOR: usize = 20;
|
||||
const MAX_SYMBOL_POOL: usize = 20_000;
|
||||
/// Matches Find in Files may report, per result asked for, when `paths` are filtered afterwards.
|
||||
const UNMASKED_MATCH_FACTOR: usize = 20;
|
||||
const MIN_UNMASKED_MATCHES: usize = 10_000;
|
||||
/// UTF-16 units of a line Find in Files reports (`ide_search`'s line window); matches
|
||||
/// outside the window of a longer line are left out.
|
||||
const LINE_WINDOW: usize = 500;
|
||||
|
||||
const PATHS_DESCRIPTION: &str = "Optional list of project-relative glob patterns to filter results. Supports '!' \
|
||||
excludes. Trailing '/' expands to '**'. Patterns without '/' are treated as '**/pattern'. Empty strings are ignored.";
|
||||
const LIMIT_DESCRIPTION: &str = "Maximum number of results to return";
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![search_text(), search_regex(), search_symbol(), search_file()]
|
||||
}
|
||||
|
||||
fn search_text() -> Tool {
|
||||
Tool::new(
|
||||
"search_text",
|
||||
"Searches for a text substring within project files.
|
||||
Use this tool for fast text search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
|call| async move { text_search(call, false).await },
|
||||
)
|
||||
.param(Param::required("q", ParamType::String, "Text to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
fn search_regex() -> Tool {
|
||||
Tool::new(
|
||||
"search_regex",
|
||||
"Searches for regex matches within project files.
|
||||
Use this tool when you need regex search with match coordinates.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
The pattern uses Rust regex syntax and is case-sensitive; prefix `(?i)` to ignore case.
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
Examples: [\"src/**\", \"!**/test/**\"], [\"**/*.kt\"], [\"foo/\"].",
|
||||
|call| async move { text_search(call, true).await },
|
||||
)
|
||||
.param(Param::required("q", ParamType::String, "Regex pattern to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
fn search_symbol() -> Tool {
|
||||
Tool::new(
|
||||
"search_symbol",
|
||||
"Searches for symbols (classes, methods, fields).
|
||||
Use this tool for semantic lookup by identifier fragments.
|
||||
Results include match coordinates when available (1-based line/column, end exclusive).
|
||||
|
||||
Paths are glob patterns relative to the project root.
|
||||
By default this searches project symbols only.
|
||||
If you don't find a suitable result, try again with include_external=true to search SDK and library symbols too.",
|
||||
|call| async move { symbol_search(call).await },
|
||||
)
|
||||
.param(Param::required("q", ParamType::String, "Symbol query text"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional(
|
||||
"include_external",
|
||||
ParamType::Boolean,
|
||||
"Whether to include SDK and library symbols. Disabled by default; if nothing suitable is found, try again \
|
||||
with include_external=true.",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
fn search_file() -> Tool {
|
||||
Tool::new(
|
||||
"search_file",
|
||||
"Searches for files by glob pattern within the project.
|
||||
Use this tool when you need to match file paths using glob syntax.
|
||||
|
||||
Glob patterns are relative to the project root.
|
||||
Examples: \"**/*.kt\", \"src/**/Foo*.java\", \"build.gradle.kts\".
|
||||
Patterns without '/' are treated as \"**/pattern\".
|
||||
Paths are optional additional glob filters relative to the project root.",
|
||||
|call| async move { file_search(call).await },
|
||||
)
|
||||
.param(Param::required("q", ParamType::String, "Glob pattern to search for"))
|
||||
.param(Param::optional("paths", ParamType::StringList, PATHS_DESCRIPTION))
|
||||
.param(Param::optional(
|
||||
"includeExcluded",
|
||||
ParamType::Boolean,
|
||||
"Whether to include excluded/ignored files in results",
|
||||
))
|
||||
.param(Param::optional("limit", ParamType::Integer, LIMIT_DESCRIPTION))
|
||||
.output(result_schema())
|
||||
}
|
||||
|
||||
// --- results -------------------------------------------------------------------
|
||||
|
||||
/// One hit. Every search tool returns these, so they share one schema: `search_file`
|
||||
/// fills only `filePath`, only `search_symbol` fills `name` and `container`.
|
||||
#[derive(Serialize, Debug, Default, Clone, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SearchItem {
|
||||
file_path: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
start_line: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
start_column: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
end_line: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
end_column: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
container: Option<String>,
|
||||
}
|
||||
|
||||
impl SearchItem {
|
||||
fn at(file_path: String, r: Range) -> Self {
|
||||
Self {
|
||||
file_path,
|
||||
start_line: Some(r.start.line + 1),
|
||||
start_column: Some(r.start.col + 1),
|
||||
end_line: Some(r.end.line + 1),
|
||||
end_column: Some(r.end.col + 1),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty fields are left out, so a search without hits is `{}`.
|
||||
#[derive(Serialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SearchResult {
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
items: Vec<SearchItem>,
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
more: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
partial_result_reason: Option<&'static str>,
|
||||
}
|
||||
|
||||
fn result_schema() -> Value {
|
||||
let int = json!({ "type": "integer" });
|
||||
let string = json!({ "type": "string" });
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"items": { "type": "array", "items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filePath": string, "startLine": int, "startColumn": int, "endLine": int, "endColumn": int,
|
||||
"name": string, "container": string,
|
||||
},
|
||||
"required": ["filePath"],
|
||||
} },
|
||||
"more": { "type": "boolean" },
|
||||
"partialResultReason": string,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn limit(call: &Call, default: i64) -> IdeResult<usize> {
|
||||
match call.opt_i64("limit")?.unwrap_or(default) {
|
||||
n if n <= 0 => Err(IdeError::invalid("limit must be > 0")),
|
||||
n => Ok((n as usize).min(MAX_RESULTS_UPPER_BOUND)),
|
||||
}
|
||||
}
|
||||
|
||||
fn query(call: &Call, empty: &str) -> IdeResult<String> {
|
||||
let q = call.str("q")?;
|
||||
if q.trim().is_empty() {
|
||||
return Err(IdeError::invalid(empty));
|
||||
}
|
||||
Ok(q)
|
||||
}
|
||||
|
||||
// --- path globs ----------------------------------------------------------------
|
||||
|
||||
/// The `paths` argument: project-relative globs, `!` excludes.
|
||||
struct PathScope {
|
||||
/// `None` when only excludes were given (everything is included).
|
||||
include: Option<GlobSet>,
|
||||
exclude: GlobSet,
|
||||
/// The globs as `FileMask` masks for Find in Files (`{a,b}` expanded, as masks
|
||||
/// split on commas), `None` when they cannot be expressed.
|
||||
masks: Option<(Vec<String>, Vec<String>)>,
|
||||
}
|
||||
|
||||
impl PathScope {
|
||||
fn new(root: &Path, patterns: &[String]) -> IdeResult<Self> {
|
||||
let (mut include, mut exclude) = (GlobSetBuilder::new(), GlobSetBuilder::new());
|
||||
let (mut include_masks, mut exclude_masks) = (Vec::new(), Vec::new());
|
||||
let mut has_include = false;
|
||||
let mut maskable = true;
|
||||
for raw in patterns {
|
||||
let raw = raw.trim();
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (negated, pattern) = match raw.strip_prefix('!') {
|
||||
Some(rest) if rest.trim().is_empty() => return Err(IdeError::invalid("Exclude pattern is empty")),
|
||||
Some(rest) => (true, rest.trim()),
|
||||
None => (false, raw),
|
||||
};
|
||||
let mut glob = normalize_glob(pattern, root)?;
|
||||
// A path naming an existing directory covers the files below it.
|
||||
if index_of_glob_char(&glob).is_none() && root.join(&glob).is_dir() {
|
||||
glob.push_str("/**");
|
||||
}
|
||||
let alternatives = expand_braces(&glob);
|
||||
maskable &= alternatives.is_some();
|
||||
let (set, masks) =
|
||||
if negated { (&mut exclude, &mut exclude_masks) } else { (&mut include, &mut include_masks) };
|
||||
set.add(build_glob(&glob)?);
|
||||
masks.extend(alternatives.into_iter().flatten());
|
||||
has_include |= !negated;
|
||||
}
|
||||
let build = |b: GlobSetBuilder| b.build().map_err(|e| IdeError::invalid(format!("Invalid glob pattern: {e}")));
|
||||
Ok(Self {
|
||||
include: if has_include { Some(build(include)?) } else { None },
|
||||
exclude: build(exclude)?,
|
||||
masks: maskable.then_some((include_masks, exclude_masks)),
|
||||
})
|
||||
}
|
||||
|
||||
fn is_all(&self) -> bool {
|
||||
self.include.is_none() && self.exclude.is_empty()
|
||||
}
|
||||
|
||||
/// `path` is project-relative with `/` (absolute outside the project).
|
||||
fn matches(&self, path: &str) -> bool {
|
||||
self.include.as_ref().is_none_or(|s| s.is_match(path)) && !self.exclude.is_match(path)
|
||||
}
|
||||
}
|
||||
|
||||
/// A glob as a project-relative pattern: `\` → `/`, leading `./` dropped, `dir/` → `dir/**`,
|
||||
/// `name` → `**/name`, and its leading directories (`..`, absolute paths) resolved against
|
||||
/// `root`. Errors when that points outside the project.
|
||||
fn normalize_glob(raw: &str, root: &Path) -> IdeResult<String> {
|
||||
let mut value = raw.trim().replace('\\', "/");
|
||||
if value.is_empty() {
|
||||
return Err(IdeError::invalid("Glob pattern is empty"));
|
||||
}
|
||||
while let Some(rest) = value.strip_prefix("./") {
|
||||
value = rest.to_owned();
|
||||
}
|
||||
if value.ends_with('/') {
|
||||
let dir = value.trim_end_matches('/');
|
||||
value = if dir.is_empty() { "**".into() } else { format!("{dir}/**") };
|
||||
}
|
||||
if !value.contains('/') {
|
||||
value = format!("**/{value}");
|
||||
}
|
||||
let value = normalize_path_pattern(&value, root)?;
|
||||
if value.is_empty() {
|
||||
return Err(IdeError::invalid(format!("Invalid glob pattern: {raw}")));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
/// Resolves the whole directory segments before the first glob character against `root`.
|
||||
/// (JetBrains resolves up to the glob character itself, turning `src/Foo*.java` into
|
||||
/// `src/Foo/*.java`; only whole segments are resolved here.)
|
||||
fn normalize_path_pattern(pattern: &str, root: &Path) -> IdeResult<String> {
|
||||
let outside = || {
|
||||
IdeError::invalid(format!("Specified path '{pattern}' points to the location outside of the project directory"))
|
||||
};
|
||||
let prefix_len = match index_of_glob_char(pattern) {
|
||||
None => pattern.len(),
|
||||
Some(i) => pattern[..i].rfind('/').map_or(0, |slash| slash + 1),
|
||||
};
|
||||
let prefix = pattern[..prefix_len].trim_end_matches('/');
|
||||
if prefix.is_empty() {
|
||||
if is_absolute_pattern(pattern) {
|
||||
return Err(outside());
|
||||
}
|
||||
return Ok(pattern.to_owned());
|
||||
}
|
||||
let prefix_path = Path::new(prefix);
|
||||
let absolute = crate::mcp::tool::normalize(&if prefix_path.is_absolute() {
|
||||
prefix_path.to_path_buf()
|
||||
} else {
|
||||
root.join(prefix_path)
|
||||
});
|
||||
let relative = relative_to_root(root, &absolute).ok_or_else(outside)?;
|
||||
let suffix = pattern[prefix_len..].trim_start_matches('/');
|
||||
Ok(match (relative.is_empty(), suffix.is_empty()) {
|
||||
(true, _) => suffix.to_owned(),
|
||||
(false, true) => relative,
|
||||
(false, false) => format!("{relative}/{suffix}"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `path` relative to `root` with `/`, also when one of them is spelled through a symlink
|
||||
/// (`/var` and `/private/var`); `None` outside `root`.
|
||||
fn relative_to_root(root: &Path, path: &Path) -> Option<String> {
|
||||
let rel = match path.strip_prefix(root) {
|
||||
Ok(rel) => rel.to_path_buf(),
|
||||
Err(_) => {
|
||||
let canon_root = dunce::canonicalize(root).ok()?;
|
||||
let existing = path.ancestors().find(|a| a.exists())?;
|
||||
let real = dunce::canonicalize(existing).ok()?.join(path.strip_prefix(existing).ok()?);
|
||||
real.strip_prefix(&canon_root).ok()?.to_path_buf()
|
||||
}
|
||||
};
|
||||
Some(rel.to_string_lossy().replace('\\', "/"))
|
||||
}
|
||||
|
||||
fn is_absolute_pattern(pattern: &str) -> bool {
|
||||
let b = pattern.as_bytes();
|
||||
pattern.starts_with('/') || (b.len() >= 3 && b[1] == b':' && b[2] == b'/')
|
||||
}
|
||||
|
||||
fn index_of_glob_char(pattern: &str) -> Option<usize> {
|
||||
pattern.find(['*', '?', '[', ']', '{', '}'])
|
||||
}
|
||||
|
||||
/// At most this many masks per glob from expanding its `{a,b}` groups.
|
||||
const MAX_BRACE_ALTERNATIVES: usize = 64;
|
||||
|
||||
/// `glob` with its `{a,b}` groups expanded into separate globs without commas, for
|
||||
/// `FileMask` (which splits on them). `None` for nested groups, braces or commas in a
|
||||
/// character class, a comma outside a group, an unbalanced brace, or too many results.
|
||||
fn expand_braces(glob: &str) -> Option<Vec<String>> {
|
||||
let mut out = vec![String::new()];
|
||||
let mut chars = glob.chars();
|
||||
let mut in_class = false;
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
'{' | '}' | ',' if in_class => return None,
|
||||
'[' => {
|
||||
in_class = true;
|
||||
out.iter_mut().for_each(|o| o.push(c));
|
||||
}
|
||||
']' => {
|
||||
in_class = false;
|
||||
out.iter_mut().for_each(|o| o.push(c));
|
||||
}
|
||||
'{' => {
|
||||
let mut group = String::new();
|
||||
loop {
|
||||
match chars.next()? {
|
||||
'}' => break,
|
||||
'{' | '[' | ']' => return None,
|
||||
g => group.push(g),
|
||||
}
|
||||
}
|
||||
let options: Vec<&str> = group.split(',').collect();
|
||||
if out.len() * options.len() > MAX_BRACE_ALTERNATIVES {
|
||||
return None;
|
||||
}
|
||||
out = out.iter().flat_map(|o| options.iter().map(move |a| format!("{o}{a}"))).collect();
|
||||
}
|
||||
'}' | ',' => return None,
|
||||
_ => out.iter_mut().for_each(|o| o.push(c)),
|
||||
}
|
||||
}
|
||||
(!in_class).then_some(out)
|
||||
}
|
||||
|
||||
/// `**/` matches zero or more directories; case follows the file system (insensitive on
|
||||
/// macOS and Windows, like Find in Files' masks).
|
||||
fn build_glob(glob: &str) -> IdeResult<globset::Glob> {
|
||||
GlobBuilder::new(glob)
|
||||
.literal_separator(true)
|
||||
.backslash_escape(false)
|
||||
.case_insensitive(cfg!(any(target_os = "macos", target_os = "windows")))
|
||||
.build()
|
||||
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {glob}")))
|
||||
}
|
||||
|
||||
// --- text / regex --------------------------------------------------------------
|
||||
|
||||
/// A match before it becomes a [`SearchItem`]; 1-based, end exclusive.
|
||||
struct TextHit {
|
||||
file_path: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
end_line: u32,
|
||||
end_column: u32,
|
||||
}
|
||||
|
||||
async fn text_search(call: Call, is_regex: bool) -> IdeResult<Output> {
|
||||
let pattern = query(&call, if is_regex { "Regex pattern is empty" } else { "Search text is empty" })?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
// Globs Find in Files cannot take are applied afterwards, so the engine must not
|
||||
// stop at `limit` matches the filter may drop.
|
||||
let (masks, max_matches) = match scope.masks.clone() {
|
||||
// One more than asked tells whether there are more.
|
||||
Some(masks) => (masks, (limit + 1) as u32),
|
||||
// Bounded all the same (as the symbol pool): past it the result reports `more`.
|
||||
None => {
|
||||
let budget = ((limit + 1) * UNMASKED_MATCH_FACTOR).max(MIN_UNMASKED_MATCHES);
|
||||
(Default::default(), u32::try_from(budget).unwrap_or(u32::MAX))
|
||||
}
|
||||
};
|
||||
let (include, exclude) = masks;
|
||||
let query = SearchQuery {
|
||||
pattern: pattern.clone(),
|
||||
is_regex,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include,
|
||||
exclude,
|
||||
scope: SearchScope::Project,
|
||||
max_matches,
|
||||
context: Default::default(),
|
||||
};
|
||||
if is_regex && let Err(e) = ide_search::match_ranges("", &query) {
|
||||
return Err(IdeError::invalid(format!("Invalid regex pattern: {pattern} ({})", e.message)));
|
||||
}
|
||||
let (files, summary, timed_out) = run_search(&call, query.clone()).await?;
|
||||
// Content roots outside the project are reported by absolute path.
|
||||
let mut files: Vec<(String, FileMatches)> =
|
||||
files.into_iter().map(|f| (call.display(&f.path.0), f)).filter(|(path, _)| scope.matches(path)).collect();
|
||||
files.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
// Only these can match a line break.
|
||||
let may_span = is_regex || pattern.contains('\n');
|
||||
let mut hits = Vec::new();
|
||||
let mut spanning = Vec::new();
|
||||
let host = call.host();
|
||||
for (path, file) in &files {
|
||||
if hits.len() > limit {
|
||||
break;
|
||||
}
|
||||
// Find in Files keeps a window of very long lines and drops the matches outside it:
|
||||
// such lines are matched again in the file's whole text.
|
||||
let long = |m: &ide_search::LineMatch| m.text_offset > 0 || m.text.encode_utf16().count() + 1 >= LINE_WINDOW;
|
||||
let whole = if file.matches.iter().any(long) {
|
||||
crate::ops::fs::read_text(&host, call.window(), file.path.0.clone())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|text| Some((ide_search::match_ranges(&text, &query).ok()?, text)))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
for m in &file.matches {
|
||||
if let Some((ranges, text)) = &whole
|
||||
&& long(m)
|
||||
{
|
||||
hits.extend(line_hits(path, text, ranges, m.line));
|
||||
continue;
|
||||
}
|
||||
let line_end = m.text_offset + m.text.encode_utf16().count() as u32;
|
||||
for &(a, b) in &m.ranges {
|
||||
let (a, b) = (m.text_offset + a, m.text_offset + b);
|
||||
if may_span && b == line_end && hits.len() < limit {
|
||||
spanning.push((hits.len(), file.path.0.clone()));
|
||||
}
|
||||
hits.push(TextHit {
|
||||
file_path: path.clone(),
|
||||
line: m.line + 1,
|
||||
column: a + 1,
|
||||
end_line: m.line + 1,
|
||||
end_column: b + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
let more = hits.len() > limit || summary.limit_hit || timed_out;
|
||||
hits.truncate(limit);
|
||||
extend_spanning(&call, &query, &mut hits, spanning).await;
|
||||
let items = hits
|
||||
.into_iter()
|
||||
.map(|h| SearchItem {
|
||||
file_path: h.file_path,
|
||||
start_line: Some(h.line),
|
||||
start_column: Some(h.column),
|
||||
end_line: Some(h.end_line),
|
||||
end_column: Some(h.end_column),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The matches of `ranges` (byte ranges in `text`) that start on 0-based `line`, with their
|
||||
/// real end (on a later line for a match spanning lines).
|
||||
fn line_hits(path: &str, text: &str, ranges: &[(usize, usize)], line: u32) -> Vec<TextHit> {
|
||||
let starts: Vec<usize> = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
|
||||
let Some(&line_start) = starts.get(line as usize) else { return Vec::new() };
|
||||
let line_end = starts.get(line as usize + 1).map_or(text.len(), |s| s - 1);
|
||||
let column = |at: usize| -> (u32, u32) {
|
||||
let l = starts.partition_point(|&s| s <= at) - 1;
|
||||
(l as u32 + 1, text[starts[l]..at].encode_utf16().count() as u32 + 1)
|
||||
};
|
||||
let first = ranges.partition_point(|r| r.0 < line_start);
|
||||
ranges[first..]
|
||||
.iter()
|
||||
.take_while(|r| r.0 <= line_end)
|
||||
.map(|&(a, b)| {
|
||||
let (end_line, end_column) = column(b);
|
||||
TextHit { file_path: path.to_owned(), line: line + 1, column: column(a).1, end_line, end_column }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A file's path, text, line start offsets and match byte ranges.
|
||||
type FileText = (PathBuf, String, Vec<usize>, Vec<(usize, usize)>);
|
||||
|
||||
/// Find in Files reports only the first line of a match that spans lines: matches
|
||||
/// ending at a line end get their real end from matching the file's text again.
|
||||
async fn extend_spanning(call: &Call, query: &SearchQuery, hits: &mut [TextHit], spanning: Vec<(usize, PathBuf)>) {
|
||||
let host = call.host();
|
||||
let mut current: Option<FileText> = None;
|
||||
for (i, path) in spanning {
|
||||
if current.as_ref().is_none_or(|(p, ..)| p != &path) {
|
||||
let Ok(text) = crate::ops::fs::read_text(&host, call.window(), path.clone()).await else { continue };
|
||||
let Ok(ranges) = ide_search::match_ranges(&text, query) else { continue };
|
||||
let starts = std::iter::once(0).chain(text.match_indices('\n').map(|(i, _)| i + 1)).collect();
|
||||
current = Some((path, text, starts, ranges));
|
||||
}
|
||||
let Some((_, text, starts, ranges)) = ¤t else { continue };
|
||||
let h = &mut hits[i];
|
||||
let Some(&line_start) = starts.get(h.line as usize - 1) else { continue };
|
||||
let Some(start) = byte_at(&text[line_start..], h.column - 1).map(|b| line_start + b) else { continue };
|
||||
let Ok(found) = ranges.binary_search_by_key(&start, |r| r.0) else { continue };
|
||||
let end = ranges[found].1;
|
||||
let end_line = starts.partition_point(|&s| s <= end) - 1;
|
||||
h.end_line = end_line as u32 + 1;
|
||||
h.end_column = text[starts[end_line]..end].encode_utf16().count() as u32 + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The byte offset of UTF-16 column `col` in `line`.
|
||||
fn byte_at(line: &str, col: u32) -> Option<usize> {
|
||||
let mut units = 0;
|
||||
for (i, c) in line.char_indices() {
|
||||
if units == col {
|
||||
return Some(i);
|
||||
}
|
||||
units += c.len_utf16() as u32;
|
||||
}
|
||||
(units == col).then_some(line.len())
|
||||
}
|
||||
|
||||
/// Runs Find in Files for at most [`MEDIUM_TIMEOUT`], cancelling it when the call is
|
||||
/// cancelled. Returns what was found and whether the time ran out.
|
||||
async fn run_search(call: &Call, query: SearchQuery) -> IdeResult<(Vec<FileMatches>, SearchSummary, bool)> {
|
||||
let host = call.host();
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let files = Arc::new(Mutex::new(Vec::new()));
|
||||
let done = Arc::new(Mutex::new(None));
|
||||
let emit: crate::ops::Emit<SearchEvent> = {
|
||||
let (files, done) = (files.clone(), done.clone());
|
||||
Arc::new(move |event| match event {
|
||||
SearchEvent::File { file } => files.lock().push(file),
|
||||
SearchEvent::Done { summary } => *done.lock() = Some(summary),
|
||||
})
|
||||
};
|
||||
let search = crate::ops::search::start(&host, call.window(), request, query, emit);
|
||||
tokio::pin!(search);
|
||||
let mut timed_out = false;
|
||||
tokio::select! {
|
||||
r = &mut search => r?,
|
||||
_ = call.cancel.cancelled() => {
|
||||
host.cancel_request(request);
|
||||
let _ = search.await;
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
_ = tokio::time::sleep(MEDIUM_TIMEOUT) => {
|
||||
host.cancel_request(request);
|
||||
let _ = search.await;
|
||||
timed_out = true;
|
||||
}
|
||||
}
|
||||
let summary = done.lock().take().unwrap_or_default();
|
||||
if summary.cancelled && !timed_out {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let files = std::mem::take(&mut *files.lock());
|
||||
Ok((files, summary, timed_out))
|
||||
}
|
||||
|
||||
// --- files ---------------------------------------------------------------------
|
||||
|
||||
async fn file_search(call: Call) -> IdeResult<Output> {
|
||||
let q = query(&call, "Glob pattern is empty")?;
|
||||
let limit = limit(&call, DEFAULT_LIMIT)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let include_excluded = call.flag("includeExcluded", false)?;
|
||||
let glob = GlobSetBuilder::new()
|
||||
.add(build_glob(&normalize_glob(&q, call.root())?)?)
|
||||
.build()
|
||||
.map_err(|_| IdeError::invalid(format!("Invalid glob pattern: {q}")))?;
|
||||
// Ignored names and "Exclude files" patterns, which `read_dir` does not flag.
|
||||
let excludes = if include_excluded { None } else { Some(FileMask::new(&[], &call.project().search_excludes())?) };
|
||||
let root = call.root().to_path_buf();
|
||||
// The project folder and the content roots outside it.
|
||||
let roots: Vec<PathBuf> = std::iter::once(root.clone())
|
||||
.chain(call.project().content_roots().into_iter().filter(|r| relative_to_root(&root, r).is_none()))
|
||||
.collect();
|
||||
let host = call.host();
|
||||
let walk = Walk {
|
||||
host: host.clone(),
|
||||
window: call.window().to_owned(),
|
||||
root,
|
||||
include_excluded,
|
||||
excludes,
|
||||
glob,
|
||||
scope,
|
||||
limit,
|
||||
cancel: call.cancel.clone(),
|
||||
deadline: Instant::now() + MEDIUM_TIMEOUT,
|
||||
timed_out: Cell::new(false),
|
||||
};
|
||||
let (paths, more, timed_out) = host
|
||||
.blocking(move || {
|
||||
let mut found = Vec::new();
|
||||
let more = roots.iter().any(|base| walk.dir(base, base, &mut found));
|
||||
if walk.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok((found, more, walk.timed_out.get()))
|
||||
})
|
||||
.await?;
|
||||
let items = paths.into_iter().map(|file_path| SearchItem { file_path, ..Default::default() }).collect();
|
||||
let partial_result_reason = timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON);
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
struct Walk {
|
||||
host: Arc<crate::Host>,
|
||||
window: String,
|
||||
root: PathBuf,
|
||||
include_excluded: bool,
|
||||
excludes: Option<FileMask>,
|
||||
glob: GlobSet,
|
||||
scope: PathScope,
|
||||
limit: usize,
|
||||
cancel: CancellationToken,
|
||||
deadline: Instant,
|
||||
timed_out: Cell<bool>,
|
||||
}
|
||||
|
||||
impl Walk {
|
||||
/// Collects matching files under `dir` (inside the walked root `base`) depth first in
|
||||
/// name order; true when it stopped early: more than `limit` match or the time ran out.
|
||||
fn dir(&self, dir: &Path, base: &Path, found: &mut Vec<String>) -> bool {
|
||||
let Ok(mut entries) = tokio::runtime::Handle::current().block_on(crate::ops::fs::read_dir(
|
||||
&self.host,
|
||||
&self.window,
|
||||
dir.to_path_buf(),
|
||||
)) else {
|
||||
return false;
|
||||
};
|
||||
entries.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
for entry in entries {
|
||||
if self.cancel.is_cancelled() {
|
||||
return false;
|
||||
}
|
||||
if Instant::now() >= self.deadline {
|
||||
self.timed_out.set(true);
|
||||
return true;
|
||||
}
|
||||
if entry.name == ".git" || (!self.include_excluded && (entry.ignored || entry.excluded)) {
|
||||
continue;
|
||||
}
|
||||
let Ok(in_base) = entry.path.strip_prefix(base) else { continue };
|
||||
if self.excludes.as_ref().is_some_and(|m| !m.matches(&in_base.to_string_lossy().replace('\\', "/"))) {
|
||||
continue;
|
||||
}
|
||||
match entry.kind {
|
||||
// Symlinked directories are not followed (they may loop).
|
||||
EntryKind::Dir => {
|
||||
if self.dir(&entry.path, base, found) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
EntryKind::File => {
|
||||
// Files of content roots outside the project go by absolute path.
|
||||
let path = relative_to_root(&self.root, &entry.path)
|
||||
.unwrap_or_else(|| entry.path.to_string_lossy().into_owned());
|
||||
if self.glob.is_match(&path) && self.scope.matches(&path) {
|
||||
if found.len() == self.limit {
|
||||
return true;
|
||||
}
|
||||
found.push(path);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
// --- symbols -------------------------------------------------------------------
|
||||
|
||||
async fn symbol_search(call: Call) -> IdeResult<Output> {
|
||||
let started = Instant::now();
|
||||
let q = query(&call, "Search query is empty")?;
|
||||
let limit = limit(&call, SYMBOL_SEARCH_DEFAULT_LIMIT)?;
|
||||
let scope = PathScope::new(call.root(), &call.str_list("paths")?)?;
|
||||
let external = call.flag("include_external", false)?;
|
||||
let host = call.host();
|
||||
let project = call.project().clone();
|
||||
let indexing_before = project.index.status().indexing;
|
||||
let pool = if scope.is_all() { limit + 1 } else { ((limit + 1) * SYMBOL_POOL_FACTOR).min(MAX_SYMBOL_POOL) };
|
||||
let found = {
|
||||
let (project, q) = (project.clone(), q.clone());
|
||||
host.blocking(move || Ok(project.index.find_symbols(&q, ide_index::SymbolFilter::All, pool))).await?
|
||||
};
|
||||
let mut items: Vec<SearchItem> = found
|
||||
.into_iter()
|
||||
.filter(|s| scope.matches(&s.rel_path))
|
||||
.map(|s| SearchItem {
|
||||
name: Some(s.name),
|
||||
container: s.container.filter(|c| !c.is_empty()),
|
||||
..SearchItem::at(s.rel_path, s.range)
|
||||
})
|
||||
.collect();
|
||||
let mut timed_out = false;
|
||||
if external && items.len() <= limit {
|
||||
let budget = MEDIUM_TIMEOUT.saturating_sub(started.elapsed());
|
||||
let (symbols, out_of_time) = workspace_symbols(&call, &q, budget).await;
|
||||
timed_out = out_of_time;
|
||||
for (name, container, location) in symbols {
|
||||
let path = location.uri.clone().unwrap_or_else(|| call.display(&location.path));
|
||||
// Project files obey `paths`; library symbols are what was asked for.
|
||||
let inside = location.uri.is_none() && !Path::new(&path).is_absolute();
|
||||
if inside && !scope.matches(&path) {
|
||||
continue;
|
||||
}
|
||||
let item = SearchItem {
|
||||
name: Some(name),
|
||||
container: container.filter(|c| !c.is_empty()),
|
||||
..SearchItem::at(path, location.range)
|
||||
};
|
||||
if !items
|
||||
.iter()
|
||||
.any(|e| e.name == item.name && e.file_path == item.file_path && e.start_line == item.start_line)
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
if items.len() > limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let more = items.len() > limit || timed_out;
|
||||
items.truncate(limit);
|
||||
let indexing = indexing_before || project.index.status().indexing;
|
||||
let partial_result_reason = if indexing {
|
||||
Some(INDEXING_PARTIAL_RESULT_REASON)
|
||||
} else {
|
||||
timed_out.then_some(SEARCH_TIMEOUT_PARTIAL_RESULT_REASON)
|
||||
};
|
||||
Output::json(SearchResult { items, more, partial_result_reason })
|
||||
}
|
||||
|
||||
/// The language servers' workspace symbols, and whether `budget` ran out first; empty
|
||||
/// when none runs or they fail.
|
||||
async fn workspace_symbols(call: &Call, q: &str, budget: Duration) -> (Vec<(String, Option<String>, Location)>, bool) {
|
||||
let host = call.host();
|
||||
let request = crate::mcp::tool::request_id();
|
||||
let symbols = crate::ops::lsp::lsp_workspace_symbols(&host, call.window(), request, q.to_owned());
|
||||
tokio::pin!(symbols);
|
||||
let result = tokio::select! {
|
||||
r = tokio::time::timeout(budget, &mut symbols) => r.ok(),
|
||||
_ = call.cancel.cancelled() => None,
|
||||
};
|
||||
match result {
|
||||
Some(Ok(symbols)) => (symbols.into_iter().map(|s| (s.name, s.container, s.location)).collect(), false),
|
||||
Some(Err(e)) => {
|
||||
tracing::debug!("MCP search_symbol: workspace symbols: {e}");
|
||||
(Vec::new(), false)
|
||||
}
|
||||
None => {
|
||||
host.cancel_request(request);
|
||||
let _ = symbols.await;
|
||||
(Vec::new(), !call.cancel.is_cancelled())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn root() -> (tempfile::TempDir, PathBuf) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(dir.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join("subdir1/nested")).unwrap();
|
||||
(dir, root)
|
||||
}
|
||||
|
||||
fn scope(root: &Path, p: &[&str]) -> PathScope {
|
||||
PathScope::new(root, &p.iter().map(|s| s.to_string()).collect::<Vec<_>>()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_globs() {
|
||||
let (_dir, root) = root();
|
||||
let n = |p: &str| normalize_glob(p, &root);
|
||||
assert_eq!(n("*.rs").unwrap(), "**/*.rs");
|
||||
assert_eq!(n("./foo/bar.txt").unwrap(), "foo/bar.txt");
|
||||
assert_eq!(n("foo/").unwrap(), "foo/**");
|
||||
assert_eq!(n("/").unwrap(), "**/**");
|
||||
assert_eq!(n("./src/**/*.ts").unwrap(), "src/**/*.ts");
|
||||
assert_eq!(n("src\\gen\\*.rs").unwrap(), "src/gen/*.rs");
|
||||
assert_eq!(n("src/a/../Foo*.java").unwrap(), "src/Foo*.java");
|
||||
assert_eq!(n(&root.join("sub/Foo.kt").to_string_lossy()).unwrap(), "sub/Foo.kt");
|
||||
assert_eq!(n(&format!("{}/**/*.kt", root.display())).unwrap(), "**/*.kt");
|
||||
for outside in ["../outside/**", "/elsewhere/*.rs", "/**"] {
|
||||
let err = n(outside).unwrap_err().message;
|
||||
assert!(err.contains("outside of the project directory"), "{outside}: {err}");
|
||||
}
|
||||
assert!(n(" ").unwrap_err().message.contains("Glob pattern is empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expands_brace_groups() {
|
||||
let e = |g: &str| expand_braces(g);
|
||||
assert_eq!(e("src/**/*.rs"), Some(vec!["src/**/*.rs".to_owned()]));
|
||||
assert_eq!(e("src/**/*.{ts,tsx}"), Some(vec!["src/**/*.ts".to_owned(), "src/**/*.tsx".to_owned()]));
|
||||
assert_eq!(e("{a,b}/*.{x,y}"), Some(["a/*.x", "a/*.y", "b/*.x", "b/*.y"].map(String::from).to_vec()));
|
||||
assert_eq!(e("a{,.bak}"), Some(vec!["a".to_owned(), "a.bak".to_owned()]));
|
||||
assert_eq!(e("[{]x"), None);
|
||||
assert_eq!(e("{a,{b,c}}"), None);
|
||||
assert_eq!(e("a,b"), None);
|
||||
assert_eq!(e("{a,b"), None);
|
||||
assert_eq!(e("a}"), None);
|
||||
assert_eq!(e(&"{a,b}".repeat(7)), None);
|
||||
assert_eq!(e(&"{a,b}".repeat(6)).map(|v| v.len()), Some(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf16_columns_to_bytes() {
|
||||
assert_eq!(byte_at("a😀b", 0), Some(0));
|
||||
assert_eq!(byte_at("a😀b", 3), Some(5));
|
||||
assert_eq!(byte_at("a😀b", 4), Some(6));
|
||||
assert_eq!(byte_at("a😀b", 2), None);
|
||||
assert_eq!(byte_at("ab", 5), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_scopes() {
|
||||
let (_dir, root) = root();
|
||||
let root = root.as_path();
|
||||
assert!(scope(root, &[]).is_all());
|
||||
assert!(scope(root, &["", " "]).is_all());
|
||||
let s = scope(root, &["subdir1/**", "!**/*.java"]);
|
||||
assert!(s.matches("subdir1/foo.txt"));
|
||||
assert!(!s.matches("subdir1/foo.java"));
|
||||
assert!(!s.matches("subdir2/foo.txt"));
|
||||
let s = scope(root, &["subdir1/**", "!subdir1/excluded/**"]);
|
||||
assert!(s.matches("subdir1/foo.txt") && !s.matches("subdir1/excluded/foo.txt"));
|
||||
// An existing directory covers the files below it; other names match files only.
|
||||
let s = scope(root, &["subdir1/nested"]);
|
||||
assert!(s.matches("subdir1/nested/foo.txt") && !s.matches("subdir1/other/foo.txt"));
|
||||
let s = scope(root, &["!gen"]);
|
||||
assert!(s.matches("src/gen/a.rs") && !s.matches("src/gen"));
|
||||
let s = scope(root, &["*.rs"]);
|
||||
assert!(s.matches("a.rs") && s.matches("x/y/a.rs") && !s.matches("a.ts"));
|
||||
assert!(s.matches("/abs/content/root/a.rs"));
|
||||
let s = scope(root, &["!*.md"]);
|
||||
assert!(s.matches("a.rs") && !s.matches("docs/a.md"));
|
||||
assert!(scope(root, &["*.{rs,ts}"]).masks.is_some());
|
||||
assert!(scope(root, &["*.{rs,{ts,js}}"]).masks.is_none());
|
||||
let err = |p: &str| PathScope::new(root, &[p.to_owned()]).err().unwrap().message;
|
||||
assert!(err("a[").contains("Invalid glob pattern"));
|
||||
assert!(err("!").contains("Exclude pattern is empty"));
|
||||
assert!(err("../x/**").contains("outside of the project directory"));
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Quick Documentation through the project's language services.
|
||||
use super::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult, Pos, Range};
|
||||
use ide_lsp::types::GotoKind;
|
||||
use serde::Serialize;
|
||||
use serde_json::json;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn get_symbol_info() -> Tool {
|
||||
Tool::new("get_symbol_info", "Retrieves information about the symbol at the specified position in the specified file.
|
||||
Provides the same information as the Quick Documentation feature of the IDE does (the language server's hover).
|
||||
|
||||
This tool is useful for getting information about the symbol at the specified position in the specified file.
|
||||
The information may include the symbol's name, signature, type, documentation, etc. It depends on a particular language.
|
||||
|
||||
If the position has a reference to a symbol the tool will return a piece of code with the declaration of the symbol if possible.
|
||||
|
||||
Use this tool to understand symbols declaration, semantics, where it's declared, etc.", get_info)
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::required("line", ParamType::Integer, "1-based line number"))
|
||||
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column number"))
|
||||
}
|
||||
|
||||
async fn get_info(call: Call) -> IdeResult<Output> {
|
||||
let path = call.resolve_readable(&call.str("filePath")?).await?;
|
||||
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
|
||||
let col = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
|
||||
let project = call.project.clone();
|
||||
let p = path.clone();
|
||||
struct Document(std::sync::Arc<crate::Project>, ide_core::DocId);
|
||||
impl Drop for Document {
|
||||
fn drop(&mut self) {
|
||||
self.0.documents.close(self.1);
|
||||
}
|
||||
}
|
||||
let (text, document) = call
|
||||
.host
|
||||
.blocking(move || {
|
||||
let text = project.text_of(&p)?;
|
||||
let doc = project.documents.open(&p)?;
|
||||
Ok((text, Document(project, doc.id)))
|
||||
})
|
||||
.await?;
|
||||
let doc = document.1;
|
||||
if line < 1 || line > u32::MAX as i64 {
|
||||
return Err(IdeError::invalid("line is out of bounds"));
|
||||
}
|
||||
let line_text = usize::try_from(line.saturating_sub(1))
|
||||
.ok()
|
||||
.and_then(|l| text.split('\n').nth(l))
|
||||
.ok_or_else(|| IdeError::invalid("Line number is out of bounds of the document"))?;
|
||||
if col < 1 || col as usize > line_text.encode_utf16().count() + 1 {
|
||||
return Err(IdeError::invalid("Column number is out of bounds of the document"));
|
||||
}
|
||||
let pos = Pos::new((line - 1) as u32, (col - 1) as u32);
|
||||
let word = word_at(line_text, char_column(line_text, col as u32));
|
||||
let mut output = json!({"documentation":""});
|
||||
let work = async {
|
||||
let hover = call.project.lsp.hover(doc, pos, call.cancel.clone()).await;
|
||||
let definitions = call.project.lsp.goto(doc, pos, GotoKind::Definition).await;
|
||||
Ok::<_, IdeError>((hover, definitions))
|
||||
};
|
||||
let result = tokio::select! {
|
||||
result = tokio::time::timeout(std::time::Duration::from_secs(20), work) => result,
|
||||
_ = call.cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
};
|
||||
match result {
|
||||
Ok(Ok((hover, definitions))) => {
|
||||
if let Ok(Some(hover)) = hover {
|
||||
output["documentation"] = hover.contents.value.into();
|
||||
}
|
||||
if let Ok(definitions) = definitions
|
||||
&& let Some(location) = definitions.iter().find(|l| l.uri.is_none())
|
||||
&& let Some(info) = symbol_info(&call, &location.path, word.clone(), location.range, 1)
|
||||
{
|
||||
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
output["partialResultReason"] = e.message.into();
|
||||
}
|
||||
Err(_) => {
|
||||
output["partialResultReason"] = "The language server did not answer in time; retry later.".into();
|
||||
}
|
||||
}
|
||||
if output.get("symbolInfo").is_none()
|
||||
&& let Some(word) = word
|
||||
{
|
||||
let project = call.project.clone();
|
||||
let path = path.clone();
|
||||
let found = call
|
||||
.host
|
||||
.blocking(move || {
|
||||
let mut symbols = project.index.symbols_named(&word, None, 50);
|
||||
symbols.sort_by_key(|s| s.path != path);
|
||||
Ok(symbols.into_iter().next())
|
||||
})
|
||||
.await?;
|
||||
if let Some(symbol) = found
|
||||
&& let Some(info) = symbol_info(&call, &symbol.path, Some(symbol.name), symbol.range, 1)
|
||||
{
|
||||
output["symbolInfo"] = serde_json::to_value(info).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
if call.project.index.status().indexing {
|
||||
output["partialResultReason"] = super::util::INDEXING_PARTIAL_RESULT_REASON.into();
|
||||
}
|
||||
Ok(Output::text(output.to_string()))
|
||||
}
|
||||
|
||||
/// The 1-based character index of 1-based UTF-16 `column` in `line`.
|
||||
fn char_column(line: &str, column: u32) -> u32 {
|
||||
let mut units = 0u32;
|
||||
let mut chars = 0u32;
|
||||
for c in line.chars() {
|
||||
if units + 1 >= column {
|
||||
break;
|
||||
}
|
||||
units += c.len_utf16() as u32;
|
||||
chars += 1;
|
||||
}
|
||||
chars + 1
|
||||
}
|
||||
|
||||
fn is_word(c: char) -> bool {
|
||||
c.is_alphanumeric() || c == '_' || c == '$'
|
||||
}
|
||||
|
||||
/// The identifier at (or just before) 1-based character `column` of `line`.
|
||||
fn word_at(line: &str, column: u32) -> Option<String> {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
let mut at = (column as usize).saturating_sub(1).min(chars.len());
|
||||
if !chars.get(at).is_some_and(|c| is_word(*c)) {
|
||||
if at > 0 && is_word(chars[at - 1]) {
|
||||
at -= 1;
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let start = (0..=at).rev().take_while(|i| is_word(chars[*i])).last()?;
|
||||
let end = (at..chars.len()).take_while(|i| is_word(chars[*i])).last()? + 1;
|
||||
Some(chars[start..end].iter().collect())
|
||||
}
|
||||
|
||||
/// Lines of comments and annotations above a declaration that count as part of it.
|
||||
const MAX_LEADING_LINES: usize = 30;
|
||||
|
||||
/// JetBrains' `SymbolInfo`.
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SymbolInfo {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
name: Option<String>,
|
||||
declaration_text: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
declaration_file: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
declaration_line: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
/// JetBrains' `getElementSymbolInfo` over a declaration's name range: from the comments and
|
||||
/// annotations right above it to the line where the name ends, plus `extra_lines`.
|
||||
fn symbol_info(
|
||||
call: &Call,
|
||||
path: &Path,
|
||||
name: Option<String>,
|
||||
name_range: Range,
|
||||
extra_lines: u32,
|
||||
) -> Option<SymbolInfo> {
|
||||
let project = call.project();
|
||||
let text = project.text_of(path).ok()?;
|
||||
let text = text.replace("\r\n", "\n");
|
||||
let lines: Vec<&str> = text.split('\n').collect();
|
||||
let name_line = name_range.start.line as usize;
|
||||
if name_line >= lines.len() {
|
||||
return None;
|
||||
}
|
||||
let language = project.languages.detect(path, lines.first().copied());
|
||||
let config = project.languages.get(&language);
|
||||
let line_comment = config.as_ref().and_then(|c| c.line_comment.clone());
|
||||
let mut start = name_line;
|
||||
while start > 0
|
||||
&& name_line - start < MAX_LEADING_LINES
|
||||
&& leads_declaration(lines[start - 1], line_comment.as_deref())
|
||||
{
|
||||
start -= 1;
|
||||
}
|
||||
let end = (name_range.end.line.max(name_range.start.line) as usize + extra_lines as usize).min(lines.len() - 1);
|
||||
Some(SymbolInfo {
|
||||
name,
|
||||
declaration_text: format!("<…>\n{}\n<…>", lines[start..=end].join("\n")),
|
||||
declaration_file: Some(call.display(path)),
|
||||
declaration_line: Some(name_line as u32 + 1),
|
||||
language: config.map(|c| c.name),
|
||||
})
|
||||
}
|
||||
|
||||
/// Doc comments, comments and annotations / attributes / decorators belong to the declaration below.
|
||||
fn leads_declaration(line: &str, line_comment: Option<&str>) -> bool {
|
||||
let t = line.trim_start();
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
["//", "/*", "*", "#[", "#![", "@"].iter().any(|p| t.starts_with(p))
|
||||
|| line_comment.is_some_and(|c| !c.is_empty() && t.starts_with(c))
|
||||
}
|
||||
|
||||
pub fn find_references() -> Tool {
|
||||
Tool::new("find_references", "Find references using the language service and the IDE's project index. Positions are 1-based UTF-16. Reports incomplete results when analysis is still running.", |call| async move {
|
||||
let path = call.resolve_readable(&call.str("filePath")?).await?;
|
||||
let line = call.opt_i64("line")?.ok_or_else(|| IdeError::invalid("missing line"))?;
|
||||
let column = call.opt_i64("column")?.ok_or_else(|| IdeError::invalid("missing column"))?;
|
||||
let project = call.project.clone();
|
||||
let (doc, text) = call.host.blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
let doc = project.documents.open(&path)?;
|
||||
Ok((doc.id, text))
|
||||
}).await?;
|
||||
struct Close(std::sync::Arc<crate::Project>, ide_core::DocId);
|
||||
impl Drop for Close { fn drop(&mut self) { self.0.documents.close(self.1); } }
|
||||
let _close = Close(call.project.clone(), doc);
|
||||
let pos = mcp_position(&text, line, column)?;
|
||||
let request = super::tool::request_id();
|
||||
let work = crate::ops::lsp::lsp_references(&call.host, &call.window, request, doc, pos, call.flag("includeDeclaration", true)?, true, false, crate::ops::Channel::new(|_| {}));
|
||||
tokio::pin!(work);
|
||||
let result = tokio::select! {
|
||||
result = &mut work => result?,
|
||||
_ = call.cancel.cancelled() => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::cancelled()); }
|
||||
_ = tokio::time::sleep(std::time::Duration::from_secs(60)) => { call.host.cancel_request(request); let _ = work.await; return Err(IdeError::unavailable("references timed out; retry when analysis finishes")); }
|
||||
};
|
||||
let mut value = serde_json::to_value(&result).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
// Convert every Location range at this boundary, including declarations.
|
||||
fn positions(value: &mut serde_json::Value) {
|
||||
match value {
|
||||
serde_json::Value::Object(map) => {
|
||||
if let (Some(line), Some(col)) = (map.get("line").and_then(|v| v.as_u64()), map.get("col").and_then(|v| v.as_u64())) {
|
||||
map.insert("line".into(), (line + 1).into()); map.insert("col".into(), (col + 1).into());
|
||||
} else { for v in map.values_mut() { positions(v); } }
|
||||
}
|
||||
serde_json::Value::Array(items) => { for v in items { positions(v); } }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
positions(&mut value);
|
||||
Output::json(value)
|
||||
})
|
||||
.param(Param::required("filePath", ParamType::String, "Path relative to the project root"))
|
||||
.param(Param::required("line", ParamType::Integer, "1-based line"))
|
||||
.param(Param::required("column", ParamType::Integer, "1-based UTF-16 column"))
|
||||
.param(Param::optional("includeDeclaration", ParamType::Boolean, "Include declarations (default true)"))
|
||||
}
|
||||
|
||||
fn mcp_position(text: &str, line: i64, column: i64) -> IdeResult<Pos> {
|
||||
if line < 1 || line > u32::MAX as i64 || column < 1 || column > u32::MAX as i64 {
|
||||
return Err(IdeError::invalid("line and column must be positive 1-based positions"));
|
||||
}
|
||||
let row = text.split('\n').nth((line - 1) as usize).ok_or_else(|| IdeError::invalid("line is out of bounds"))?;
|
||||
if column as usize > row.encode_utf16().count() + 1 {
|
||||
return Err(IdeError::invalid("column is out of bounds"));
|
||||
}
|
||||
Ok(Pos::new((line - 1) as u32, (column - 1) as u32))
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
use crate::{Host, Project};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct Call {
|
||||
pub client: String,
|
||||
pub tool: String,
|
||||
pub host: Arc<Host>,
|
||||
pub project: Arc<Project>,
|
||||
pub window: String,
|
||||
pub args: Value,
|
||||
pub cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Call {
|
||||
pub fn opt_str(&self, name: &str) -> IdeResult<Option<String>> {
|
||||
self.args
|
||||
.get(name)
|
||||
.filter(|v| !v.is_null())
|
||||
.map(|v| v.as_str().map(str::to_owned).ok_or_else(|| IdeError::invalid(format!("{name} must be a string"))))
|
||||
.transpose()
|
||||
}
|
||||
pub fn str_map(&self, name: &str) -> IdeResult<Vec<(String, String)>> {
|
||||
self.args
|
||||
.get(name)
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| IdeError::invalid(format!("{name} must be an object")))?
|
||||
.iter()
|
||||
.map(|(k, v)| {
|
||||
v.as_str()
|
||||
.map(|v| (k.clone(), v.to_owned()))
|
||||
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
pub fn resolve_inside(&self, path: &str) -> IdeResult<PathBuf> {
|
||||
resolve_inside(self.root(), path)
|
||||
}
|
||||
pub async fn resolve_writable(&self, path: &str) -> IdeResult<PathBuf> {
|
||||
let path = self.resolve_inside(path)?;
|
||||
self.check_writable(&path).await?;
|
||||
Ok(path)
|
||||
}
|
||||
pub async fn check_writable(&self, path: &Path) -> IdeResult<()> {
|
||||
self.project.require_trust()?;
|
||||
ensure_inside(self.root(), path)
|
||||
}
|
||||
pub async fn ask(&self, request: ide_core::event::McpUiRequest) -> IdeResult<()> {
|
||||
self.host.mcp_requests.ask(self, request).await
|
||||
}
|
||||
pub async fn confirm(&self, subject: &str, detail: Option<String>) -> IdeResult<()> {
|
||||
self.ask(ide_core::event::McpUiRequest::Confirm {
|
||||
client: self.client.clone(),
|
||||
tool: self.tool.clone(),
|
||||
subject: subject.into(),
|
||||
project: self.root().to_string_lossy().into_owned(),
|
||||
detail,
|
||||
})
|
||||
.await?;
|
||||
self.project.require_trust()
|
||||
}
|
||||
pub fn host(&self) -> Arc<Host> {
|
||||
self.host.clone()
|
||||
}
|
||||
pub fn project(&self) -> &Arc<Project> {
|
||||
&self.project
|
||||
}
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.project.root
|
||||
}
|
||||
pub fn window(&self) -> &str {
|
||||
&self.window
|
||||
}
|
||||
pub fn str(&self, name: &str) -> IdeResult<String> {
|
||||
self.args
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| IdeError::invalid(format!("missing or invalid argument `{name}`")))
|
||||
}
|
||||
pub fn opt_i64(&self, name: &str) -> IdeResult<Option<i64>> {
|
||||
self.args
|
||||
.get(name)
|
||||
.map(|v| v.as_i64().ok_or_else(|| IdeError::invalid(format!("{name} must be an integer"))))
|
||||
.transpose()
|
||||
}
|
||||
pub fn flag(&self, name: &str, default: bool) -> IdeResult<bool> {
|
||||
self.args
|
||||
.get(name)
|
||||
.map(|v| v.as_bool().ok_or_else(|| IdeError::invalid(format!("{name} must be a boolean"))))
|
||||
.unwrap_or(Ok(default))
|
||||
}
|
||||
pub fn str_list(&self, name: &str) -> IdeResult<Vec<String>> {
|
||||
match self.args.get(name) {
|
||||
None => Ok(Vec::new()),
|
||||
Some(Value::Array(values)) => values
|
||||
.iter()
|
||||
.map(|v| {
|
||||
v.as_str()
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| IdeError::invalid(format!("{name} must contain strings")))
|
||||
})
|
||||
.collect(),
|
||||
_ => Err(IdeError::invalid(format!("{name} must be an array"))),
|
||||
}
|
||||
}
|
||||
pub fn display(&self, path: &Path) -> String {
|
||||
path.strip_prefix(self.root()).unwrap_or(path).to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
pub async fn resolve_readable(&self, path: &str) -> IdeResult<PathBuf> {
|
||||
let root = self.root().to_path_buf();
|
||||
let mut roots = self.project.content_roots();
|
||||
roots.extend(self.host.mcp_reports.libraries(&self.host.mcp_windows(self.root())));
|
||||
roots.extend(
|
||||
self.project
|
||||
.settings
|
||||
.effective()
|
||||
.project_structure
|
||||
.libraries
|
||||
.iter()
|
||||
.flat_map(|l| l.sources.iter().chain(&l.classes).chain(&l.javadocs))
|
||||
.map(|p| self.root().join(p)),
|
||||
);
|
||||
let path = path.to_owned();
|
||||
self.host
|
||||
.blocking(move || {
|
||||
let path = if path.starts_with("file://") {
|
||||
ide_lsp::uri_to_path(&path).ok_or_else(|| IdeError::invalid("invalid file URL"))?
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
};
|
||||
let path = if path.is_absolute() { path } else { root.join(path) };
|
||||
let (local, entry) =
|
||||
ide_vfs::split_archive_path(&path).map_or((path.clone(), None), |(p, e)| (p, Some(e)));
|
||||
let real = if let Ok(real) = dunce::canonicalize(&local) {
|
||||
real
|
||||
} else {
|
||||
let parent =
|
||||
local.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::not_found("path not found"))?;
|
||||
normalize(
|
||||
&dunce::canonicalize(parent)?
|
||||
.join(local.strip_prefix(parent).map_err(|e| IdeError::invalid(e.to_string()))?),
|
||||
)
|
||||
};
|
||||
if !std::iter::once(root).chain(roots).any(|r| {
|
||||
let r = ide_vfs::split_archive_path(&r).map_or(r.clone(), |(archive, _)| archive);
|
||||
dunce::canonicalize(r).is_ok_and(|r| real.starts_with(r))
|
||||
}) {
|
||||
return Err(IdeError::invalid("path is outside the project's content roots"));
|
||||
}
|
||||
Ok(entry.map_or_else(|| real.clone(), |entry| PathBuf::from(format!("{}!/{entry}", real.display()))))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Output(pub String);
|
||||
impl Output {
|
||||
pub fn text(text: impl Into<String>) -> Self {
|
||||
Self(text.into())
|
||||
}
|
||||
pub fn json(value: impl Serialize) -> IdeResult<Self> {
|
||||
serde_json::to_string(&value).map(Self).map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum ParamType {
|
||||
String,
|
||||
Integer,
|
||||
Boolean,
|
||||
StringList,
|
||||
StringMap,
|
||||
}
|
||||
pub struct Param {
|
||||
name: &'static str,
|
||||
kind: ParamType,
|
||||
description: &'static str,
|
||||
required: bool,
|
||||
}
|
||||
impl Param {
|
||||
pub fn required(name: &'static str, kind: ParamType, description: &'static str) -> Self {
|
||||
Self { name, kind, description, required: true }
|
||||
}
|
||||
pub fn optional(name: &'static str, kind: ParamType, description: &'static str) -> Self {
|
||||
Self { name, kind, description, required: false }
|
||||
}
|
||||
}
|
||||
|
||||
type Handler = Arc<dyn Fn(Call) -> futures::future::BoxFuture<'static, IdeResult<Output>> + Send + Sync>;
|
||||
pub struct Tool {
|
||||
pub metadata: ide_mcp::Tool,
|
||||
pub handler: Handler,
|
||||
}
|
||||
impl Tool {
|
||||
pub fn new<F, Fut>(name: &str, description: &str, handler: F) -> Self
|
||||
where
|
||||
F: Fn(Call) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = IdeResult<Output>> + Send + 'static,
|
||||
{
|
||||
Self {
|
||||
metadata: ide_mcp::Tool {
|
||||
name: name.into(),
|
||||
description: description.into(),
|
||||
read_only: true,
|
||||
destructive: false,
|
||||
output_schema: None,
|
||||
input_schema: json!({ "type": "object", "properties": {
|
||||
"projectPath": { "type": "string", "description": "Any directory inside an open project. Optional when only one project is open." }
|
||||
}, "required": [] }),
|
||||
},
|
||||
handler: Arc::new(move |call| Box::pin(handler(call))),
|
||||
}
|
||||
}
|
||||
pub fn write(mut self) -> Self {
|
||||
self.metadata.read_only = false;
|
||||
self
|
||||
}
|
||||
pub fn param(mut self, param: Param) -> Self {
|
||||
let mut schema = match param.kind {
|
||||
ParamType::String => json!({"type":"string"}),
|
||||
ParamType::Integer => json!({"type":"integer"}),
|
||||
ParamType::Boolean => json!({"type":"boolean"}),
|
||||
ParamType::StringMap => json!({"type":"object","additionalProperties":{"type":"string"}}),
|
||||
ParamType::StringList => json!({"type":"array","items":{"type":"string"}}),
|
||||
};
|
||||
schema["description"] = param.description.into();
|
||||
self.metadata.input_schema["properties"][param.name] = schema;
|
||||
if param.required {
|
||||
self.metadata.input_schema["required"].as_array_mut().unwrap().push(param.name.into());
|
||||
}
|
||||
self
|
||||
}
|
||||
pub fn output(mut self, schema: Value) -> Self {
|
||||
self.metadata.output_schema = Some(schema);
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize(path: &Path) -> PathBuf {
|
||||
let mut out = PathBuf::new();
|
||||
for part in path.components() {
|
||||
match part {
|
||||
std::path::Component::CurDir => {}
|
||||
std::path::Component::ParentDir => {
|
||||
out.pop();
|
||||
}
|
||||
p => out.push(p.as_os_str()),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn request_id() -> ide_core::RequestId {
|
||||
static NEXT: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0x8000_0000);
|
||||
ide_core::RequestId(NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed))
|
||||
}
|
||||
|
||||
/// Resolve existing ancestors too, so symlinks cannot escape the project.
|
||||
pub fn resolve_inside(root: &Path, raw: &str) -> IdeResult<PathBuf> {
|
||||
let path = if raw.starts_with("file://") {
|
||||
ide_lsp::uri_to_path(raw).ok_or_else(|| IdeError::invalid("invalid file URL"))?
|
||||
} else {
|
||||
PathBuf::from(raw)
|
||||
};
|
||||
let path = if path.is_absolute() { path } else { root.join(path) };
|
||||
if path.components().any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git")) {
|
||||
return Err(IdeError::invalid("VCS metadata cannot be changed"));
|
||||
}
|
||||
let path = normalize(&path);
|
||||
let ancestor =
|
||||
path.ancestors().find(|p| p.exists()).ok_or_else(|| IdeError::invalid("path has no existing parent"))?;
|
||||
let mut real = dunce::canonicalize(ancestor)?;
|
||||
let suffix = path.strip_prefix(ancestor).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !suffix.as_os_str().is_empty() {
|
||||
real.push(suffix);
|
||||
}
|
||||
let root = dunce::canonicalize(root)?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(IdeError::invalid("path is outside the project"));
|
||||
}
|
||||
if real
|
||||
.strip_prefix(&root)
|
||||
.unwrap()
|
||||
.components()
|
||||
.any(|c| c.as_os_str().to_string_lossy().eq_ignore_ascii_case(".git"))
|
||||
{
|
||||
return Err(IdeError::invalid("VCS metadata cannot be changed"));
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
pub fn ensure_inside(root: &Path, path: &Path) -> IdeResult<()> {
|
||||
resolve_inside(root, &path.to_string_lossy()).map(|_| ())
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server (src/com/intellij/mcpserver/util/fs.util.kt,
|
||||
// util/text.util.kt, toolsets/Constants.kt), Apache-2.0.
|
||||
//! Helpers shared by the tools: the strings and timeouts of JetBrains' `Constants`,
|
||||
//! resolving the paths `read_file` accepts, and text truncation.
|
||||
|
||||
use crate::mcp::tool::Call;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const RELATIVE_PATH_IN_PROJECT_DESCRIPTION: &str = "Path relative to the project root";
|
||||
pub const FILE_PATH_DESCRIPTION: &str = "Path to the file. Supports project-relative paths, paths with '..', \
|
||||
absolute paths, archive entries like '/path/lib.jar!/pkg/Foo.java', and URLs such as 'file://' and 'jar://'. \
|
||||
Any path returned from the other tools can be passed as is (e.g. paths from 'search_*' tools).";
|
||||
pub const TIMEOUT_MILLISECONDS_DESCRIPTION: &str = "Timeout in milliseconds";
|
||||
pub const TIMED_OUT_DESCRIPTION: &str = "Indicates whether the operation was timed out. 'true' value may mean that \
|
||||
the results may be incomplete or partial. 'false', 'null' or missing value means that the operation has not been \
|
||||
timed out.";
|
||||
pub const MEDIUM_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
pub const INDEXING_PARTIAL_RESULT_REASON: &str =
|
||||
"Project indexing was in progress; results may be incomplete — retry once indexing completes.";
|
||||
|
||||
/// Whether `path` starts with a URL scheme (`file://`, `jar://`, `jrt://` …).
|
||||
pub fn looks_like_vfs_url(path: &str) -> bool {
|
||||
path.find("://")
|
||||
.is_some_and(|i| i > 0 && path[..i].chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')))
|
||||
}
|
||||
|
||||
/// Resolves `read_file`'s `file_path`: project-relative or absolute paths, archive entries
|
||||
/// (`/lib.jar!/pkg/Foo.java`) and `file://` / `jar://` URLs, within known content or library roots.
|
||||
pub async fn resolve_read_file(call: &Call, file_path: &str) -> IdeResult<PathBuf> {
|
||||
let normalized = file_path.trim().replace('\\', "/");
|
||||
if normalized.is_empty() {
|
||||
return Err(IdeError::invalid("file_path is empty"));
|
||||
}
|
||||
let missing = || IdeError::not_found(format!("File {file_path} doesn't exist or can't be opened"));
|
||||
let local = if looks_like_vfs_url(&normalized) {
|
||||
let (scheme, rest) = normalized.split_once("://").unwrap_or_default();
|
||||
match scheme.to_ascii_lowercase().as_str() {
|
||||
// `file://` URLs are percent-decoded by `resolve`.
|
||||
"file" => normalized.clone(),
|
||||
"jar" => rest.to_owned(),
|
||||
"jrt" => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"File {file_path} can't be opened: OpenIDEA does not read jrt:// (Java runtime image) entries"
|
||||
)));
|
||||
}
|
||||
_ => return Err(missing()),
|
||||
}
|
||||
} else {
|
||||
normalized
|
||||
};
|
||||
let path = call.resolve_readable(&local).await?;
|
||||
match ide_vfs::split_archive_path(&path) {
|
||||
Some((archive, entry)) => {
|
||||
if !archive.is_file() {
|
||||
return Err(missing());
|
||||
}
|
||||
if entry.is_empty() || ide_vfs::read_archive_dir(&archive, &entry).is_ok() {
|
||||
return Err(IdeError::invalid(format!("File {file_path} is a directory")));
|
||||
}
|
||||
ide_vfs::probe(&path).map_err(|_| missing())?;
|
||||
}
|
||||
None if path.is_dir() => return Err(IdeError::invalid(format!("File {file_path} is a directory"))),
|
||||
None if !path.is_file() && call.project().documents.by_path(&path).is_none() => return Err(missing()),
|
||||
None => {}
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -1,362 +0,0 @@
|
||||
// Ported from JetBrains intellij-community plugins/mcp-server
|
||||
// (mcpserver.vcs/src/com/intellij/mcpserver/toolsets/vcs/VcsToolset.kt), Apache-2.0.
|
||||
//! Version control: repositories and git status.
|
||||
|
||||
use crate::mcp::tool::{Call, Output, Param, ParamType, Tool};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const STATUS_DEFAULT_LIMIT: i64 = 1000;
|
||||
|
||||
pub fn tools() -> Vec<Tool> {
|
||||
vec![get_repositories(), git_status(), git_diff()]
|
||||
}
|
||||
|
||||
/// `path` with symbolic links resolved (macOS `/var` is `/private/var`), as given when it
|
||||
/// does not exist.
|
||||
fn canonical(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
/// A path relative to the project root with `/`; empty for the root itself. Either may
|
||||
/// name the other through a symbolic link.
|
||||
fn relative_to(root: &Path, path: &Path) -> String {
|
||||
let rel = match path.strip_prefix(root) {
|
||||
Ok(rel) => Some(rel.to_path_buf()),
|
||||
Err(_) => canonical(path).strip_prefix(canonical(root)).ok().map(Path::to_path_buf),
|
||||
};
|
||||
match rel {
|
||||
Some(rel) => rel.to_string_lossy().replace('\\', "/"),
|
||||
None => path.to_string_lossy().into_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn relative_to_project(call: &Call, path: &Path) -> String {
|
||||
relative_to(call.root(), path)
|
||||
}
|
||||
|
||||
/// The repository whose roots contain `path` most specifically (paths compared canonically).
|
||||
fn innermost_repo<T>(repos: Vec<(PathBuf, T)>, path: &Path) -> Option<(PathBuf, T)> {
|
||||
let path = canonical(path);
|
||||
repos.into_iter().filter(|r| path.starts_with(canonical(&r.0))).max_by_key(|r| canonical(&r.0).components().count())
|
||||
}
|
||||
|
||||
// Read tools must never invoke repository-configured executables, including in Restricted Mode.
|
||||
fn read_command(git: &str, root: &Path) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(git);
|
||||
command
|
||||
.current_dir(root)
|
||||
.args([
|
||||
"-c",
|
||||
"core.fsmonitor=false",
|
||||
"-c",
|
||||
"core.hooksPath=",
|
||||
"-c",
|
||||
"diff.external=",
|
||||
"-c",
|
||||
"core.attributesFile=",
|
||||
"-c",
|
||||
"submodule.recurse=false",
|
||||
"-c",
|
||||
"color.ui=false",
|
||||
])
|
||||
.env("GIT_NO_LAZY_FETCH", "1")
|
||||
.env("GIT_TERMINAL_PROMPT", "0")
|
||||
.env("GIT_OPTIONAL_LOCKS", "0")
|
||||
.env_remove("GIT_EXTERNAL_DIFF")
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_INDEX_FILE");
|
||||
ide_core::process::prepare(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
/// The branch HEAD is on (unborn too); `None` when HEAD is detached or git fails.
|
||||
fn read_branch(git: &str, root: &Path) -> Option<String> {
|
||||
let mut cmd = read_command(git, root);
|
||||
cmd.args(["symbolic-ref", "--short", "-q", "HEAD"]);
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.output().ok().filter(|o| o.status.success())?;
|
||||
Some(String::from_utf8_lossy(&out.stdout).trim().to_owned()).filter(|b| !b.is_empty())
|
||||
}
|
||||
|
||||
fn get_repositories() -> Tool {
|
||||
Tool::new(
|
||||
"get_repositories",
|
||||
"Retrieves the list of VCS roots in the project.
|
||||
This is useful to detect all repositories in a multi-repository project.
|
||||
In OpenIDEA each root also tells its current `branch` (absent when HEAD is detached), `headCommit` and a merge, \
|
||||
rebase or cherry-pick in progress (`operationInProgress`).",
|
||||
|call| async move {
|
||||
let host = call.host();
|
||||
let window = call.window.clone();
|
||||
let roots = call.host.blocking(move || crate::ops::vcs::vcs_roots(&host, &window)).await?;
|
||||
let list: Vec<Value> = roots
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let mut v =
|
||||
json!({ "pathRelativeToProject": relative_to_project(&call, &r.root), "vcsName": "Git" });
|
||||
if let Some(head) = r.head {
|
||||
v["branch"] = head.into();
|
||||
}
|
||||
if let Some(commit) = r.head_commit {
|
||||
v["headCommit"] = commit.into();
|
||||
}
|
||||
if let Some(op) = r.operation {
|
||||
v["operationInProgress"] = op.into();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
Output::json(json!({ "roots": list }))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// One entry of `git status --porcelain -z`.
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct StatusEntry {
|
||||
index: char,
|
||||
work_tree: char,
|
||||
path: String,
|
||||
orig_path: Option<String>,
|
||||
}
|
||||
|
||||
impl StatusEntry {
|
||||
fn untracked(&self) -> bool {
|
||||
self.index == '?'
|
||||
}
|
||||
fn ignored(&self) -> bool {
|
||||
self.index == '!'
|
||||
}
|
||||
fn conflicted(&self) -> bool {
|
||||
matches!((self.index, self.work_tree), ('D', 'D') | ('A', 'U') | ('U', _) | ('A', 'A') | ('D', 'U'))
|
||||
}
|
||||
fn staged(&self) -> bool {
|
||||
!self.conflicted() && !matches!(self.index, ' ' | '?' | '!')
|
||||
}
|
||||
fn unstaged(&self) -> bool {
|
||||
!self.conflicted() && !matches!(self.work_tree, ' ' | '?' | '!')
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_status(out: &str) -> Vec<StatusEntry> {
|
||||
let mut fields = out.split('\0').filter(|f| !f.is_empty());
|
||||
let mut entries = Vec::new();
|
||||
while let Some(field) = fields.next() {
|
||||
let mut chars = field.chars();
|
||||
let (Some(index), Some(work_tree)) = (chars.next(), chars.next()) else { continue };
|
||||
let path = field.get(3..).unwrap_or_default().to_owned();
|
||||
let orig_path = matches!(index, 'R' | 'C').then(|| fields.next().map(str::to_owned)).flatten();
|
||||
entries.push(StatusEntry { index, work_tree, path, orig_path });
|
||||
}
|
||||
entries
|
||||
}
|
||||
|
||||
fn git_executable(call: &Call) -> String {
|
||||
let settings = if call.project.is_trusted() {
|
||||
call.project.settings.effective()
|
||||
} else {
|
||||
call.host.global_settings.effective()
|
||||
};
|
||||
let exe = settings.vcs.git.executable.trim().to_owned();
|
||||
if exe.is_empty() { "git".to_owned() } else { exe }
|
||||
}
|
||||
|
||||
/// `git status` of the repository at `root`, paths relative to it.
|
||||
fn read_status(git: &str, root: &Path, untracked: bool, ignored: bool) -> IdeResult<Vec<StatusEntry>> {
|
||||
let mut cmd = read_command(git, root);
|
||||
cmd.args(["-c", "core.quotepath=false", "status", "--porcelain", "-z", "--find-renames"]);
|
||||
// git refuses `--ignored=matching` with `--untracked-files=no`: untracked files are dropped below.
|
||||
cmd.arg(if untracked || ignored { "--untracked-files=all" } else { "--untracked-files=no" });
|
||||
if ignored {
|
||||
cmd.arg("--ignored=matching");
|
||||
}
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let out = cmd.output().map_err(|e| IdeError::unavailable(format!("cannot run git: {e}")))?;
|
||||
if !out.status.success() {
|
||||
return Err(IdeError::internal(String::from_utf8_lossy(&out.stderr).trim().to_owned()));
|
||||
}
|
||||
let mut entries = parse_status(&String::from_utf8_lossy(&out.stdout));
|
||||
entries.retain(|e| untracked || !e.untracked());
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
/// A git repository in the project, for paths outside the repositories IDEA knows: the
|
||||
/// closest folder with `.git` from `start` up to the project root.
|
||||
fn find_git_root(root: &Path, start: &Path) -> Option<PathBuf> {
|
||||
let (root, start) = (canonical(root), canonical(start));
|
||||
let mut current = if start.is_dir() { start.as_path() } else { start.parent().unwrap_or(&start) };
|
||||
while current.starts_with(&root) {
|
||||
if current.join(".git").exists() {
|
||||
return Some(current.to_path_buf());
|
||||
}
|
||||
current = current.parent()?;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn git_status() -> Tool {
|
||||
Tool::new(
|
||||
"git_status",
|
||||
"Retrieves Git status for one or more repositories in the current project.
|
||||
Returns porcelain-style index/worktree status codes and summary counters.
|
||||
By default all Git repositories are returned.",
|
||||
|call| async move {
|
||||
let include_untracked = call.flag("includeUntracked", true)?;
|
||||
let include_ignored = call.flag("includeIgnored", false)?;
|
||||
let limit = call.opt_i64("limit")?.unwrap_or(STATUS_DEFAULT_LIMIT);
|
||||
if limit <= 0 {
|
||||
return Err(IdeError::invalid("limit must be > 0"));
|
||||
}
|
||||
let limit = limit as usize;
|
||||
let host = call.host();
|
||||
let h = host.clone();
|
||||
let w = call.window.clone();
|
||||
let repos: Vec<(PathBuf, Option<String>)> = host
|
||||
.blocking(move || crate::ops::vcs::vcs_roots(&h, &w))
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|r| (r.root.into_path_buf(), r.head))
|
||||
.collect();
|
||||
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
|
||||
let git = git_executable(&call);
|
||||
let mut roots: Vec<(PathBuf, Option<String>)> = match &wanted {
|
||||
None => repos,
|
||||
Some(wanted) => {
|
||||
let path = call.resolve_inside(wanted)?;
|
||||
// The innermost repository containing the path.
|
||||
match innermost_repo(repos, &path) {
|
||||
Some(repo) => vec![repo],
|
||||
None => match find_git_root(call.root(), &path) {
|
||||
Some(root) => {
|
||||
// JetBrains reads the branch of any repository it reports.
|
||||
let (git, dir) = (git.clone(), root.clone());
|
||||
let branch = host.blocking(move || Ok(read_branch(&git, &dir))).await?;
|
||||
vec![(root, branch)]
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::not_found(format!("No git repository found for path: {wanted}")));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
roots.sort_by_key(|r| relative_to_project(&call, &r.0));
|
||||
let mut repositories = Vec::new();
|
||||
for (root, branch) in roots {
|
||||
let rel = relative_to_project(&call, &root);
|
||||
let (git, dir) = (git.clone(), root.clone());
|
||||
let entries = host
|
||||
.blocking(move || read_status(&git, &dir, include_untracked, include_ignored))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
IdeError::internal(format!("Failed to get git status for repository '{rel}': {}", e.message))
|
||||
})?;
|
||||
let count = |f: fn(&StatusEntry) -> bool| entries.iter().filter(|e| f(e)).count();
|
||||
let listed: Vec<Value> = entries
|
||||
.iter()
|
||||
.take(limit)
|
||||
.map(|e| {
|
||||
let mut v = json!({
|
||||
"pathRelativeToRepository": e.path.trim_end_matches('/'),
|
||||
"indexStatus": e.index.to_string(),
|
||||
"workTreeStatus": e.work_tree.to_string(),
|
||||
});
|
||||
if let Some(orig) = &e.orig_path {
|
||||
v["originalPathRelativeToRepository"] = orig.clone().into();
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
repositories.push(json!({
|
||||
"repositoryPathRelativeToProject": rel,
|
||||
"currentBranch": branch,
|
||||
"isClean": entries.is_empty(),
|
||||
"totalEntries": entries.len(),
|
||||
"hasMoreEntries": entries.len() > listed.len(),
|
||||
"stagedCount": count(StatusEntry::staged),
|
||||
"unstagedCount": count(StatusEntry::unstaged),
|
||||
"untrackedCount": count(StatusEntry::untracked),
|
||||
"ignoredCount": count(StatusEntry::ignored),
|
||||
"conflictedCount": count(StatusEntry::conflicted),
|
||||
"entries": listed,
|
||||
}));
|
||||
}
|
||||
Output::json(json!({ "repositories": repositories }))
|
||||
},
|
||||
)
|
||||
.param(Param::optional(
|
||||
"repositoryPathRelativeToProject",
|
||||
ParamType::String,
|
||||
"Optional path relative to project root used to select a single containing repository",
|
||||
))
|
||||
.param(Param::optional("includeUntracked", ParamType::Boolean, "Whether to include untracked files"))
|
||||
.param(Param::optional("includeIgnored", ParamType::Boolean, "Whether to include ignored files"))
|
||||
.param(Param::optional("limit", ParamType::Integer, "Maximum number of entries returned per repository"))
|
||||
}
|
||||
|
||||
fn git_diff() -> Tool {
|
||||
Tool::new("git_diff", "Read a Git diff for a file or the working tree. staged=true compares the index with HEAD; otherwise compares disk with the index. Unsaved editor text is not part of this diff.", |call| async move {
|
||||
let wanted = call.opt_str("repositoryPathRelativeToProject")?;
|
||||
let path = wanted.as_deref().map(|p| call.resolve_inside(p)).transpose()?.unwrap_or_else(|| call.root().to_path_buf());
|
||||
let root = find_git_root(call.root(), &path).ok_or_else(|| IdeError::not_found("No git repository found"))?;
|
||||
let file = call.opt_str("filePath")?.map(|p| call.resolve_inside(&p)).transpose()?;
|
||||
if file.as_ref().is_some_and(|p| !p.starts_with(&root)) { return Err(IdeError::invalid("file is outside the repository")); }
|
||||
let staged = call.flag("staged", false)?;
|
||||
let git = git_executable(&call);
|
||||
let cancel = call.cancel.clone();
|
||||
let text = call.host.blocking(move || {
|
||||
let mut cmd = read_command(&git, &root);
|
||||
cmd.args(["--no-pager", "diff", "--no-ext-diff", "--no-textconv"]);
|
||||
if staged { cmd.arg("--cached"); }
|
||||
cmd.arg("--");
|
||||
if let Some(file) = file { cmd.arg(file.strip_prefix(&root).map_err(|e| IdeError::invalid(e.to_string()))?); }
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
if cancel.is_cancelled() { return Err(IdeError::cancelled()); }
|
||||
let out = cmd.output()?;
|
||||
if !out.status.success() { return Err(IdeError::unavailable(String::from_utf8_lossy(&out.stderr).trim())); }
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}).await?;
|
||||
Ok(Output::text(text))
|
||||
})
|
||||
.param(Param::optional("repositoryPathRelativeToProject", ParamType::String, "Select the containing repository"))
|
||||
.param(Param::optional("filePath", ParamType::String, "Optional project-relative file; omit for the working tree"))
|
||||
.param(Param::optional("staged", ParamType::Boolean, "Show staged changes"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_porcelain_z_with_renames() {
|
||||
let out = "M a.txt\0R new.txt\0old.txt\0?? u.txt\0UU c.txt\0";
|
||||
let e = parse_status(out);
|
||||
assert_eq!(e.len(), 4);
|
||||
assert_eq!(e[1].path, "new.txt");
|
||||
assert_eq!(e[1].orig_path.as_deref(), Some("old.txt"));
|
||||
assert!(e[0].staged() && !e[0].unstaged());
|
||||
assert!(e[2].untracked() && !e[2].staged());
|
||||
assert!(e[3].conflicted() && !e[3].staged() && !e[3].unstaged());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn paths_through_a_symlinked_root_compare_canonically() {
|
||||
// As macOS `/var` → `/private/var`: the project opened through the link, git reporting real paths.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dunce::canonicalize(dir.path()).unwrap().join("real");
|
||||
std::fs::create_dir_all(real.join("sub/.git")).unwrap();
|
||||
std::fs::write(real.join("sub/f.txt"), "x").unwrap();
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&real, &link).unwrap();
|
||||
assert_eq!(relative_to(&link, &real.join("sub")), "sub");
|
||||
assert_eq!(relative_to(&real, &link.join("sub")), "sub");
|
||||
assert_eq!(relative_to(&link, &link), "");
|
||||
let repos = vec![(real.clone(), 1), (real.join("sub"), 2)];
|
||||
assert_eq!(innermost_repo(repos, &link.join("sub/f.txt")).map(|r| r.1), Some(2));
|
||||
assert_eq!(find_git_root(&link, &real.join("sub/f.txt")), Some(real.join("sub")));
|
||||
}
|
||||
}
|
||||
@@ -134,22 +134,21 @@ pub async fn inspect_code(
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let total = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> =
|
||||
files.into_iter().filter(|f| project.lsp.serves_path(&f.path)).map(|f| (f.path, f.rel_path)).collect();
|
||||
Ok((total, rel))
|
||||
})
|
||||
.await
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
};
|
||||
let (total_in_scope, rel) = match listed {
|
||||
let files = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! Maven / Gradle tool windows (the build model and its background sync) and
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
mod workspace;
|
||||
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
@@ -12,6 +10,7 @@ use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Pos, Range, RequestId, WorkspaceEdit};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
@@ -126,8 +125,8 @@ fn with_profiles(mut model: BuildModel, cache: &Path) -> BuildModel {
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || workspace::load_project(&project, &config)).await.map_err(join_err)?
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
fn linked_gradle_paths(
|
||||
@@ -225,89 +224,84 @@ pub async fn build_sync(
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let bridge_profiles = profiles.clone();
|
||||
// What the Java language server and Build Project resolve the project with from now on.
|
||||
let profiles_changed = tool == BuildTool::Maven
|
||||
&& ide_build::save_maven_profiles(&cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if profiles_changed {
|
||||
// The modules of the chosen profiles are read from the POMs with them from now on (the
|
||||
// project tree's module folders too), whether or not Maven itself can run.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let completion_token = token.clone();
|
||||
let bridge_profiles = profiles.clone();
|
||||
let p = project.clone();
|
||||
let config = host.config_dir.clone();
|
||||
let original_links = settings.languages.java.gradle.linked_projects.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let selected: Vec<_> =
|
||||
scopes.iter().filter(|scope| workspace::selected(scope, &p.root, tool, build_dir.as_deref())).collect();
|
||||
if selected.is_empty() {
|
||||
return Err(IdeError::not_found("Build project is no longer linked"));
|
||||
}
|
||||
let mut profiles_changed = false;
|
||||
for scope in selected {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let (root, cache, settings) = (&scope.root, &scope.cache, &scope.settings);
|
||||
let current = load(root, cache, settings);
|
||||
if tool == BuildTool::Maven && current.maven.is_some() {
|
||||
let changed = ide_build::save_maven_profiles(cache, &profiles).unwrap_or_else(|e| {
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if changed {
|
||||
p.sink.emit(IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
|
||||
}
|
||||
profiles_changed |= changed;
|
||||
}
|
||||
let env = current
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default();
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles: profiles.clone(),
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(root, settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(root, settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven if current.maven.is_some() => {
|
||||
ide_build::sync_maven(root, cache, &options, &token, &progress)?
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => BTreeMap::new(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
maven_settings: settings.languages.java.maven.clone(),
|
||||
gradle_command: None,
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => {
|
||||
if let Some(dir) = &build_dir
|
||||
&& !current.gradle.iter().any(|g| &g.dir == dir)
|
||||
{
|
||||
return Err(IdeError::not_found("Gradle project is no longer linked"));
|
||||
}
|
||||
BuildTool::Maven => {}
|
||||
BuildTool::Gradle => {
|
||||
for g in current
|
||||
.gradle
|
||||
.iter()
|
||||
.filter(|g| build_dir.as_ref().is_none_or(|dir| p.root.join(dir) == root.join(&g.dir)))
|
||||
{
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options = SyncOptions {
|
||||
env: g.env.clone(),
|
||||
gradle_command: Some(g.command.clone()),
|
||||
..options.clone()
|
||||
};
|
||||
ide_build::sync_gradle_model(root, cache, g, &options, &token, &progress)?;
|
||||
for g in current.gradle.iter().filter(|g| build_dir.as_ref().is_none_or(|dir| dir == &g.dir)) {
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let options =
|
||||
SyncOptions { env: g.env.clone(), gradle_command: Some(g.command.clone()), ..options.clone() };
|
||||
ide_build::sync_gradle_model(&root, &cache, g, &options, &token, &progress)?;
|
||||
}
|
||||
if token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
}
|
||||
}
|
||||
let scopes = workspace::scopes(&p, &config)?;
|
||||
let model = workspace::load_scopes(&p.root, &scopes);
|
||||
let updates = workspace::update_files(&p.root, &scopes, &model, tool);
|
||||
Ok::<_, IdeError>((model, profiles_changed, updates))
|
||||
Ok::<BuildModel, IdeError>(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let (model, profiles_changed, updates) = result??;
|
||||
let model = result??;
|
||||
if completion_token.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if tool == BuildTool::Gradle && original_links != project.settings.effective().languages.java.gradle.linked_projects
|
||||
{
|
||||
// A late sync must not re-import a build that was unlinked while it ran.
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
load(&project.root, &project.build_cache_dir(), &project.settings.effective())
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
}
|
||||
if tool == BuildTool::Gradle {
|
||||
// The sync may report project folders outside the project (`includeFlat`): index and list them.
|
||||
project.refresh_index_scope();
|
||||
@@ -326,17 +320,30 @@ pub async fn build_sync(
|
||||
// to update the project configurations re-resolves them (#436). The same
|
||||
// holds for Gradle: it reuses its cached classpath on restart, so a
|
||||
// dependency added to `build.gradle` stays unresolved until told to sync.
|
||||
let build_files: Vec<String> = match tool {
|
||||
BuildTool::Maven => model.maven.iter().flat_map(|m| m.projects.iter().map(|p| p.pom.clone())).collect(),
|
||||
BuildTool::Gradle => {
|
||||
model.gradle.iter().flat_map(|g| g.projects.iter().filter_map(|p| p.build_file.clone())).collect()
|
||||
}
|
||||
};
|
||||
let lsp = project.lsp.clone();
|
||||
// With a module of a reactor unlinked, jdtls imports the other modules one by one and
|
||||
// not the reactor: naming the reactor here would import it again, all modules included.
|
||||
let imported = match tool {
|
||||
BuildTool::Maven => crate::project::jdtls_maven_import(&project.root, &project.settings.effective()).1,
|
||||
BuildTool::Gradle => None,
|
||||
};
|
||||
let uris = match imported {
|
||||
Some(files) => files.into_iter().filter(|f| f.ends_with("/pom.xml")).collect(),
|
||||
None => build_files.iter().map(|f| ide_lsp::path_to_uri_string(&project.root.join(f))).collect(),
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
if profiles_changed {
|
||||
// jdtls takes the profiles from its Maven settings (see `ide_sdk::maven_profiles`).
|
||||
lsp.reconfigure().await;
|
||||
}
|
||||
for file in updates {
|
||||
let uri = ide_lsp::path_to_uri_string(&file);
|
||||
if let Err(e) = lsp.java_update_project_configs(&file, vec![uri]).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
if let Err(e) = lsp.java_update_project_configs(&project.root, uris).await {
|
||||
tracing::debug!("java/projectConfigurationsUpdate: {e}");
|
||||
}
|
||||
});
|
||||
Ok(model)
|
||||
@@ -346,25 +353,12 @@ pub async fn build_sync(
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
project: &Project,
|
||||
config: &Path,
|
||||
pom: &str,
|
||||
dependency: &ArtifactKey,
|
||||
) -> IdeResult<(std::path::PathBuf, String, Range)> {
|
||||
let scopes = workspace::scopes(project, config)?;
|
||||
let path = project.root.join(pom);
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.filter(|s| path.starts_with(&s.root))
|
||||
.max_by_key(|s| s.root.components().count())
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings).maven.unwrap_or_default();
|
||||
let pom = model
|
||||
.projects
|
||||
.iter()
|
||||
.find(|p| scope.root.join(&p.pom) == path)
|
||||
.ok_or_else(|| IdeError::not_found("Maven project is no longer linked"))?;
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, &pom.pom) {
|
||||
let path = scope.root.join(&rel);
|
||||
let model = ide_build::maven::load(&project.root);
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
|
||||
let path = project.root.join(&rel);
|
||||
let text = project.text_of(&path)?;
|
||||
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
|
||||
return Ok((path, text, range));
|
||||
@@ -404,19 +398,14 @@ pub async fn gradle_dependencies(
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let scopes = workspace::scopes(&project, &config)?;
|
||||
let scope = scopes
|
||||
.iter()
|
||||
.find(|s| workspace::selected(s, &project.root, BuildTool::Gradle, build_dir.as_deref()))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
let model = load(&root, &cache, &settings);
|
||||
let gradle = model
|
||||
.gradle
|
||||
.iter()
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| project.root.join(dir) == scope.root.join(&g.dir)))
|
||||
.find(|g| build_dir.as_ref().is_none_or(|dir| &g.dir == dir))
|
||||
.ok_or_else(|| IdeError::not_found("Gradle project is no longer linked"))?;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
@@ -424,7 +413,7 @@ pub async fn gradle_dependencies(
|
||||
gradle_command: Some(gradle.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&scope.root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
ide_build::gradle_dependencies(&root, gradle, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
@@ -440,9 +429,8 @@ pub async fn maven_dependency_declaration(
|
||||
dependency: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, _, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
@@ -481,9 +469,8 @@ pub async fn maven_exclude_dependency(
|
||||
exclusion: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
let config = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, text, range) = declaring_pom(&project, &config, &pom, &dependency)?;
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit = WorkspaceEdit {
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(super) struct Scope {
|
||||
pub root: PathBuf,
|
||||
pub cache: PathBuf,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>> {
|
||||
scopes_at(&project.root, &project.build_cache_dir(), config, project.settings.effective())
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
Ok(match ide_workspace::workspace::Workspace::load(root)? {
|
||||
Some(workspace) => workspace
|
||||
.projects
|
||||
.into_iter()
|
||||
.filter(|p| p.is_dir())
|
||||
.map(|root| Scope {
|
||||
cache: cache.join(crate::project::project_key(&root)),
|
||||
settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(),
|
||||
root,
|
||||
})
|
||||
.collect(),
|
||||
None => vec![Scope { root: root.to_owned(), cache: cache.to_owned(), settings }],
|
||||
})
|
||||
}
|
||||
|
||||
fn absolute(root: &Path, path: &str) -> String {
|
||||
let path = if path.is_empty() { root.to_owned() } else { root.join(path) };
|
||||
ide_core::ipc_path::to_ipc(&path)
|
||||
}
|
||||
|
||||
pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel {
|
||||
let mut combined = BuildModel::default();
|
||||
for scope in scopes {
|
||||
let mut model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
if scope.root == root {
|
||||
return model;
|
||||
}
|
||||
if let Some(maven) = &mut model.maven {
|
||||
let owner = ide_core::ipc_path::to_ipc(&scope.root);
|
||||
for p in &mut maven.projects {
|
||||
p.project_root = Some(owner.clone());
|
||||
p.pom = absolute(&scope.root, &p.pom);
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
for module in &mut p.modules {
|
||||
*module = absolute(&scope.root, module);
|
||||
}
|
||||
}
|
||||
for profile in &mut maven.profiles {
|
||||
profile.project_root = Some(owner.clone());
|
||||
}
|
||||
}
|
||||
if let Some(maven) = model.maven {
|
||||
match &mut combined.maven {
|
||||
None => combined.maven = Some(maven),
|
||||
Some(all) => {
|
||||
all.projects.extend(maven.projects);
|
||||
all.profiles.extend(maven.profiles);
|
||||
all.synced_at = match (all.synced_at, maven.synced_at) {
|
||||
(Some(a), Some(b)) => Some(a.min(b)),
|
||||
_ => None,
|
||||
};
|
||||
all.stale |= maven.stale;
|
||||
if let Some(error) = maven.sync_error {
|
||||
all.sync_error = Some(match all.sync_error.take() {
|
||||
Some(previous) => format!("{previous}\n{error}"),
|
||||
None => error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for g in &mut model.gradle {
|
||||
g.dir = absolute(&scope.root, &g.dir);
|
||||
for p in &mut g.projects {
|
||||
p.dir = absolute(&scope.root, &p.dir);
|
||||
p.build_file = p.build_file.as_ref().map(|f| absolute(&scope.root, f));
|
||||
}
|
||||
}
|
||||
combined.gradle.extend(model.gradle);
|
||||
}
|
||||
combined
|
||||
}
|
||||
|
||||
pub(super) fn load_project(project: &Project, config: &Path) -> IdeResult<BuildModel> {
|
||||
Ok(load_scopes(&project.root, &scopes(project, config)?))
|
||||
}
|
||||
|
||||
pub(super) fn selected(scope: &Scope, window_root: &Path, tool: BuildTool, dir: Option<&str>) -> bool {
|
||||
let Some(dir) = dir else { return true };
|
||||
match tool {
|
||||
BuildTool::Maven => scope.root == Path::new(dir),
|
||||
BuildTool::Gradle => load(&scope.root, &scope.cache, &scope.settings)
|
||||
.gradle
|
||||
.iter()
|
||||
.any(|g| window_root.join(dir) == scope.root.join(&g.dir)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build files select their member's language server, including independent nested builds.
|
||||
pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, tool: BuildTool) -> Vec<PathBuf> {
|
||||
let mut files = Vec::new();
|
||||
match tool {
|
||||
BuildTool::Maven => {
|
||||
for scope in scopes {
|
||||
// A reactor with unlinked modules must not be imported as a whole again.
|
||||
let imported = crate::project::jdtls_maven_import(&scope.root, &scope.settings).1;
|
||||
let paths: Vec<_> = match imported {
|
||||
Some(uris) => uris
|
||||
.iter()
|
||||
.filter_map(|u| ide_lsp::uri_to_path(u))
|
||||
.filter(|p| p.file_name().is_some_and(|n| n == "pom.xml"))
|
||||
.collect(),
|
||||
None => model
|
||||
.maven
|
||||
.iter()
|
||||
.flat_map(|m| &m.projects)
|
||||
.filter(|p| p.project_root.as_ref().is_none_or(|owner| Path::new(owner) == scope.root))
|
||||
.map(|p| root.join(&p.pom))
|
||||
.collect(),
|
||||
};
|
||||
for path in paths {
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
BuildTool::Gradle => {
|
||||
for file in model.gradle.iter().flat_map(|g| &g.projects).filter_map(|p| p.build_file.as_ref()) {
|
||||
let path = root.join(file);
|
||||
if !files.contains(&path) {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
files
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn workspace_models_use_member_settings_paths_and_caches() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let workspace = base.join("workspace");
|
||||
let config = base.join("config");
|
||||
let cache = base.join("cache");
|
||||
let members: Vec<_> = ["first", "second"].into_iter().map(|name| base.join(name)).collect();
|
||||
for member in &members {
|
||||
std::fs::create_dir_all(member.join("extra")).unwrap();
|
||||
std::fs::write(
|
||||
member.join("pom.xml"),
|
||||
r#"<project><artifactId>app</artifactId>
|
||||
<profiles><profile><id>extra</id><modules><module>extra</module></modules></profile></profiles>
|
||||
</project>"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(member.join("extra/pom.xml"), "<project><artifactId>extra</artifactId></project>").unwrap();
|
||||
std::fs::write(member.join("settings.gradle"), "rootProject.name = 'app'").unwrap();
|
||||
std::fs::write(member.join("build.gradle"), "").unwrap();
|
||||
}
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", members.clone(), true).unwrap();
|
||||
let first_cache = cache.join(crate::project::project_key(&members[0]));
|
||||
ide_build::save_maven_profiles(&first_cache, &["extra".into()]).unwrap();
|
||||
let settings = ide_workspace::SettingsStore::load(&config, Some(&members[1]));
|
||||
settings
|
||||
.set(
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
"languages.java.gradle.linkedProjects",
|
||||
serde_json::json!([]),
|
||||
)
|
||||
.unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(scopes[0].cache, first_cache);
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
let maven = model.maven.as_ref().unwrap();
|
||||
assert_eq!(maven.projects.len(), 3);
|
||||
assert_eq!(maven.projects[0].modules, [absolute(&members[0], "extra/pom.xml")]);
|
||||
assert!(maven.projects.iter().all(|p| Path::new(&p.pom).is_absolute() && Path::new(&p.dir).is_absolute()));
|
||||
assert_eq!(maven.profiles.len(), 2);
|
||||
assert_ne!(maven.profiles[0].project_root, maven.profiles[1].project_root);
|
||||
let updates = update_files(&workspace, &scopes, &model, BuildTool::Maven);
|
||||
assert_eq!(updates.len(), 3);
|
||||
assert!(updates.iter().all(|p| members.iter().any(|root| p.starts_with(root))));
|
||||
assert_eq!(update_files(&workspace, &scopes, &model, BuildTool::Gradle), [members[0].join("build.gradle")]);
|
||||
assert_eq!(model.gradle.len(), 1);
|
||||
assert_eq!(model.gradle[0].dir, absolute(&members[0], ""));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Maven, Some(&absolute(&members[0], ""))));
|
||||
assert!(selected(&scopes[0], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert!(!selected(&scopes[1], &workspace, BuildTool::Gradle, Some(&model.gradle[0].dir)));
|
||||
assert_eq!(ide_build::maven_profiles(&scopes[1].cache), Vec::<String>::new());
|
||||
let standalone = scopes_at(&members[0], &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&members[0], &standalone), load(&members[0], &cache, &Settings::default()));
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Workspace", vec![members[1].clone()], false).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
let model = load_scopes(&workspace, &scopes);
|
||||
assert_eq!(model.maven.unwrap().projects.len(), 1);
|
||||
assert!(model.gradle.is_empty());
|
||||
std::fs::remove_dir_all(&members[1]).unwrap();
|
||||
let scopes = scopes_at(&workspace, &cache, &config, Settings::default()).unwrap();
|
||||
assert_eq!(load_scopes(&workspace, &scopes), BuildModel::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reload_keeps_unlinked_modules_out_of_member_language_servers() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(temp.path()).unwrap();
|
||||
for name in ["enabled", "hidden"] {
|
||||
std::fs::create_dir(root.join(name)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(name).join("pom.xml"),
|
||||
format!("<project><artifactId>{name}</artifactId></project>"),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(root.join("pom.xml"), "<project><artifactId>parent</artifactId><packaging>pom</packaging><modules><module>enabled</module><module>hidden</module></modules></project>").unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.maven.unlinked_projects = vec!["hidden/pom.xml".into()];
|
||||
let scope = Scope { root: root.clone(), cache: root.join("cache"), settings };
|
||||
let model = load(&scope.root, &scope.cache, &scope.settings);
|
||||
assert_eq!(update_files(&root, &[scope], &model, BuildTool::Maven), [root.join("enabled/pom.xml")]);
|
||||
}
|
||||
}
|
||||
@@ -169,12 +169,7 @@ pub fn sources(host: &Host, window: &str) -> IdeResult<Vec<DataSource>> {
|
||||
/// Replaces the project's data sources with `sources`.
|
||||
pub async fn save_sources(host: &Host, window: &str, sources: Vec<DataSource>) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.save_sources(sources).await?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(ide_core::IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
Ok(())
|
||||
db.save_sources(sources).await
|
||||
}
|
||||
|
||||
/// Remembers the password of data source `id` for the session, and in the OS
|
||||
|
||||
@@ -75,20 +75,8 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
config.extra_json = Some(extra.to_string());
|
||||
}
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let mut ruby_tools = Vec::new();
|
||||
if config.adapter == "rdbg" && config.request == "launch" {
|
||||
let project_root = host.project(window).map(|p| p.root.clone()).unwrap_or_else(|_| root.clone());
|
||||
let (dir, settings) = (root.clone(), settings.clone());
|
||||
(config, ruby_tools) = host
|
||||
.blocking(move || {
|
||||
let tools = ide_sdk::ruby::debug_environment(&project_root, &dir, &settings, &mut config.env);
|
||||
Ok((config, tools))
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)
|
||||
.or_else(|| ruby_tools.into_iter().find(|(name, _)| *name == command).map(|(_, path)| path))?;
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
|
||||
@@ -52,20 +52,6 @@ pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: L
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
let lsp = if lsp.as_ref().is_none_or(|v| v.is_empty()) {
|
||||
if let Some((plugins, d)) =
|
||||
crate::ops::lsp::plugin_providers(host, window, doc, crate::plugins::ProviderKind::DocumentSymbols)
|
||||
{
|
||||
let items =
|
||||
crate::ops::lsp::plugin_call(move || plugins.language_document_symbols(&d)).await.unwrap_or_default();
|
||||
if items.is_empty() { lsp } else { Some(crate::plugins::language_features::outline(items)) }
|
||||
} else {
|
||||
lsp
|
||||
}
|
||||
} else {
|
||||
lsp
|
||||
};
|
||||
let lsp = lsp.filter(|v| !v.is_empty());
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
@@ -94,27 +80,7 @@ pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut folds = project.syntax.folding(doc, &snap.language, snap.version, &snap.text);
|
||||
if let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::Folding) {
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
folds.extend(plugins.language_folding(&d).into_iter().map(|f| FoldRange {
|
||||
start_line: f.start_line,
|
||||
end_line: f.end_line,
|
||||
kind: match f.kind.as_str() {
|
||||
"comment" => ide_language::FoldKind::Comment,
|
||||
"imports" => ide_language::FoldKind::Imports,
|
||||
_ => ide_language::FoldKind::Region,
|
||||
},
|
||||
}));
|
||||
folds.sort_by_key(|f| (f.start_line, f.end_line));
|
||||
folds.dedup_by_key(|f| (f.start_line, f.end_line));
|
||||
}
|
||||
Ok(folds)
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -139,21 +105,6 @@ pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset:
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if !snap.large
|
||||
&& let Some(plugins) = crate::plugins::editor_providers_for(crate::plugins::ProviderKind::SelectionRanges)
|
||||
{
|
||||
let at = ide_text::Document::new(&snap.text).offset_to_pos(offset);
|
||||
let d = ide_plugin::ProviderDocument {
|
||||
path: project.documents.identity(doc)?.0,
|
||||
language: snap.language.0.clone(),
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text.clone(),
|
||||
};
|
||||
let ranges = plugins.language_selection_ranges(&d, at);
|
||||
if !ranges.is_empty() {
|
||||
return Ok(ranges);
|
||||
}
|
||||
}
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId};
|
||||
use ide_lsp::jdtls::{JavaBuildStatus, JavaLaunchInfo};
|
||||
use ide_run::BuildPolicy;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Cancellable with `cancel_request(request)`.
|
||||
@@ -20,12 +19,12 @@ pub async fn java_resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
policy: BuildPolicy,
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, policy, &cancel).await;
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -35,17 +34,13 @@ async fn resolve_launch(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
policy: BuildPolicy,
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// A launch builds incrementally unless the configuration asks for Rebuild or NoBuild.
|
||||
let build = policy != BuildPolicy::NoBuild;
|
||||
let full = policy == BuildPolicy::Rebuild;
|
||||
let build_kind = if full { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let (model, external, delegated) = {
|
||||
let project = project.clone();
|
||||
let settings = settings.clone();
|
||||
@@ -64,7 +59,7 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
};
|
||||
let external_build = if delegated { Some(project.lsp.java_external_build(&module, cancel).await?) } else { None };
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, full, delegated, cancel).await?;
|
||||
let mut info = project.lsp.java_launch(&module, test, build && !delegated, delegated, cancel).await?;
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.local_data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
@@ -75,23 +70,17 @@ async fn resolve_launch(
|
||||
&project.root,
|
||||
&model,
|
||||
&module,
|
||||
cache::BuildOptions { test, policy },
|
||||
test,
|
||||
ide_sdk::apply::maven_local_repository(&project.root, &settings),
|
||||
&cancel,
|
||||
)?;
|
||||
let copies = use_snapshot(&mut info, &outputs.originals, &outputs.snapshot);
|
||||
apply_external_build_result(&mut info, outputs.failure);
|
||||
info.build = Some(JavaBuildStatus::Succeeded);
|
||||
info.errors.clear();
|
||||
info.error_count = 0;
|
||||
copies
|
||||
} else if external {
|
||||
let built = compile::build_for_launch(&project.root, &model, &module, test, build_kind, &cancel)?;
|
||||
if let Some(failure) = built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure));
|
||||
}
|
||||
info.build = Some(JavaBuildStatus::Failed);
|
||||
info.hints.push(failure);
|
||||
}
|
||||
built.class_dirs
|
||||
compile::build_for_launch(&project.root, &model, &module, test, &cancel)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
@@ -149,18 +138,6 @@ async fn resolve_launch(
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
fn apply_external_build_result(info: &mut JavaLaunchInfo, failure: Option<String>) {
|
||||
info.build = Some(if let Some(failure) = failure {
|
||||
info.hints.push(failure);
|
||||
JavaBuildStatus::Failed
|
||||
} else if info.error_count > 0 {
|
||||
// Incremental Maven compilation can accept error-stub classes written by JDT.
|
||||
JavaBuildStatus::WithErrors
|
||||
} else {
|
||||
JavaBuildStatus::Succeeded
|
||||
});
|
||||
}
|
||||
|
||||
/// A classpath entry as a comparable key. Entries come from JDT LS and from our own
|
||||
/// build model, which spell one directory differently on Windows (`\\?\` prefix,
|
||||
/// `/` or `\\`, drive-letter case); compared as text, a module output stayed on the
|
||||
@@ -258,63 +235,27 @@ pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
// HotSwap may cancel the compile (the request id is allocated by the caller).
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = project.lsp.java_build(&module, false, &cancel).await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("policy")?, c.arg("request")?)
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?, c.arg("request")?)
|
||||
.await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move {
|
||||
java_build_workspace(&c.host, &c.window, c.arg("module")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn successful_external_compilation_does_not_erase_scoped_jdt_errors() {
|
||||
for (failure, expected) in
|
||||
[(None, JavaBuildStatus::WithErrors), (Some("compile failed"), JavaBuildStatus::Failed)]
|
||||
{
|
||||
let mut info = JavaLaunchInfo {
|
||||
error_count: 1,
|
||||
errors: vec![ide_lsp::jdtls::JavaCompileError {
|
||||
path: "src/main/java/Main.java".into(),
|
||||
line: 4,
|
||||
column: 28,
|
||||
message: "missingName cannot be resolved to a variable".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let errors = info.errors.clone();
|
||||
apply_external_build_result(&mut info, failure.map(str::to_owned));
|
||||
assert_eq!(info.build, Some(expected));
|
||||
assert_eq!(info.error_count, 1);
|
||||
assert_eq!(info.errors, errors);
|
||||
assert_eq!(info.hints, failure.into_iter().collect::<Vec<_>>());
|
||||
}
|
||||
let mut info = JavaLaunchInfo::default();
|
||||
apply_external_build_result(&mut info, None);
|
||||
assert_eq!(info.build, Some(JavaBuildStatus::Succeeded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
|
||||
use ide_core::settings::{DependencyScope, ProjectLibrary};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
//! Immutable Maven outputs retained in the project cache, including across close/reopen.
|
||||
use ide_build::compile::{self, LaunchFingerprint};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_run::BuildPolicy;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
@@ -12,8 +11,6 @@ pub(super) struct Outputs {
|
||||
fingerprint: Option<LaunchFingerprint>,
|
||||
pub snapshot: PathBuf,
|
||||
pub originals: Vec<PathBuf>,
|
||||
#[serde(skip)]
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
impl Outputs {
|
||||
@@ -38,12 +35,6 @@ pub(super) fn has_record(root: &Path, cache: &Path, module: &Path) -> bool {
|
||||
[false, true].into_iter().any(|test| record_path(root, cache, module, test).is_some_and(|record| record.is_file()))
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct BuildOptions {
|
||||
pub test: bool,
|
||||
pub policy: BuildPolicy,
|
||||
}
|
||||
|
||||
/// Owned by Project: running JVMs may still load superseded outputs until it drops.
|
||||
pub(crate) struct Session {
|
||||
cache: PathBuf,
|
||||
@@ -70,12 +61,11 @@ impl Session {
|
||||
root: &Path,
|
||||
model: &ide_build::BuildModel,
|
||||
module: &Path,
|
||||
options: BuildOptions,
|
||||
test: bool,
|
||||
local_repo: Option<PathBuf>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outputs> {
|
||||
self.initialize();
|
||||
let BuildOptions { test, policy } = options;
|
||||
let cache = &self.cache;
|
||||
let record =
|
||||
record_path(root, cache, module, test).ok_or_else(|| IdeError::internal("launch outside project"))?;
|
||||
@@ -91,7 +81,6 @@ impl Session {
|
||||
let before = fingerprint()?;
|
||||
let previous = fs::read(&record).ok().and_then(|bytes| serde_json::from_slice::<Outputs>(&bytes).ok());
|
||||
if let Some(outputs) = previous.as_ref()
|
||||
&& policy != BuildPolicy::Rebuild
|
||||
&& outputs.reusable(&before, &snapshots)
|
||||
{
|
||||
tracing::info!(module = %module.display(), test, "Reusing Maven launch snapshot");
|
||||
@@ -101,27 +90,10 @@ impl Session {
|
||||
// source still delegates to Maven to clean its old classes on the next launch.
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
fs::write(&record, b"null")?;
|
||||
if let Some(previous) = &previous {
|
||||
self.unreferenced.push(previous.snapshot.clone());
|
||||
if let Some(previous) = previous {
|
||||
self.unreferenced.push(previous.snapshot);
|
||||
}
|
||||
let kind = if policy == BuildPolicy::Rebuild { compile::BuildKind::Rebuild } else { compile::BuildKind::Build };
|
||||
let built = compile::build_for_launch(root, model, module, test, kind, cancel)?;
|
||||
if let Some(failure) = &built.failure {
|
||||
if policy != BuildPolicy::MakeAndContinueOnErrors {
|
||||
return Err(IdeError::internal(failure.clone()));
|
||||
}
|
||||
if let Some(mut previous) =
|
||||
previous.filter(|p| (0..p.originals.len()).all(|i| p.snapshot.join(i.to_string()).is_dir()))
|
||||
{
|
||||
previous.fingerprint = None;
|
||||
previous.failure = built.failure;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
serde_json::to_writer(file.as_file_mut(), &previous).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
file.persist(&record).map_err(|e| IdeError::from(e.error))?;
|
||||
return Ok(previous);
|
||||
}
|
||||
}
|
||||
let originals = built.class_dirs;
|
||||
let originals = compile::build_for_launch(root, model, module, test, cancel)?;
|
||||
fs::create_dir_all(&snapshots)?;
|
||||
let snapshot = tempfile::Builder::new().prefix("launch-").tempdir_in(&snapshots)?.keep();
|
||||
// Retain every new snapshot even if copying or publishing its record fails.
|
||||
@@ -136,8 +108,8 @@ impl Session {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals, failure: built.failure };
|
||||
if outputs.failure.is_none() && before.is_some() && before == after {
|
||||
let mut outputs = Outputs { fingerprint: None, snapshot, originals };
|
||||
if before.is_some() && before == after {
|
||||
outputs.fingerprint = before;
|
||||
fs::create_dir_all(record.parent().unwrap())?;
|
||||
let mut file = tempfile::NamedTempFile::new_in(record.parent().unwrap())?;
|
||||
@@ -227,77 +199,13 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
let script = root.join("mvnw");
|
||||
fs::write(&script, "#!/bin/sh\necho build \"$@\" >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::write(&script, "#!/bin/sh\necho build >> calls\nif [ -f src/main/kotlin/broken ]; then exit 1; fi\nmkdir -p target/classes\ncp src/main/kotlin/Main.kt target/classes/Main.class\n").unwrap();
|
||||
fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
fs::create_dir_all(root.join("src/main/kotlin")).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "first").unwrap();
|
||||
ide_build::load(root, &root.join("cache"), None)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_bypasses_snapshots_and_error_continuation_retains_previous_classes() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
let model = fixture(root);
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(root.join("cache"));
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let rebuilt = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::Rebuild, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(first.snapshot, rebuilt.snapshot);
|
||||
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 2);
|
||||
assert!(fs::read_to_string(root.join("calls")).unwrap().lines().last().unwrap().contains("clean"));
|
||||
fs::write(root.join("src/main/kotlin/broken"), "broken").unwrap();
|
||||
let continued = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(continued.snapshot, rebuilt.snapshot);
|
||||
assert!(continued.failure.unwrap().contains("Build failed"));
|
||||
assert_eq!(fs::read_to_string(continued.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
let again = session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(again.snapshot, rebuilt.snapshot);
|
||||
assert!(again.failure.is_some());
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
session
|
||||
.build(
|
||||
root,
|
||||
&model,
|
||||
root,
|
||||
BuildOptions { policy: BuildPolicy::MakeAndContinueOnErrors, ..Default::default() },
|
||||
None,
|
||||
&cancel
|
||||
)
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successful_snapshots_reuse_across_close_and_rebuild_for_changes_missing_outputs_or_failure() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -306,7 +214,7 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let mut build = || session.build(root, &model, root, BuildOptions::default(), None, &cancel);
|
||||
let mut build = || session.build(root, &model, root, false, None, &cancel);
|
||||
let first = build().unwrap();
|
||||
let snapshot = first.snapshot.clone();
|
||||
drop(first);
|
||||
@@ -345,14 +253,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
|
||||
|
||||
let source = root.join("src/main/kotlin/Main.kt");
|
||||
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
|
||||
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
|
||||
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
|
||||
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
|
||||
}
|
||||
@@ -365,9 +273,9 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "updated").unwrap();
|
||||
let second = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let second = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
// Another mode can keep a superseded snapshot alive.
|
||||
@@ -378,10 +286,10 @@ mod tests {
|
||||
assert!(first.snapshot.is_dir());
|
||||
assert!(second.snapshot.is_dir());
|
||||
let mut session = Session::new(cache.clone());
|
||||
let reused = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let reused = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_eq!(reused.snapshot, second.snapshot);
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "third version").unwrap();
|
||||
let third = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let third = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert!(second.snapshot.is_dir());
|
||||
drop(session);
|
||||
assert!(!second.snapshot.exists());
|
||||
@@ -408,7 +316,7 @@ mod tests {
|
||||
fs::write(snapshots.join("launch-file"), "keep").unwrap();
|
||||
let record = cache.join("java-launch/records/deep/module/test.json");
|
||||
fs::create_dir_all(record.parent().unwrap()).unwrap();
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new(), failure: None };
|
||||
let outputs = Outputs { fingerprint: None, snapshot: retained.clone(), originals: Vec::new() };
|
||||
fs::write(record, serde_json::to_vec(&outputs).unwrap()).unwrap();
|
||||
let mut session = Session::new(cache);
|
||||
session.initialize();
|
||||
@@ -439,7 +347,7 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).is_err());
|
||||
assert!(session.build(root, &model, root, false, None, &CancellationToken::new()).is_err());
|
||||
assert_eq!(session.unreferenced.len(), 1);
|
||||
let snapshot = session.unreferenced[0].clone();
|
||||
assert!(snapshot.join("0/Main.class").is_file());
|
||||
@@ -458,8 +366,7 @@ mod tests {
|
||||
fs::write(script, text).unwrap();
|
||||
let cache = root.join("cache");
|
||||
let mut session = Session::new(cache.clone());
|
||||
let outputs =
|
||||
session.build(root, &model, root, BuildOptions::default(), None, &CancellationToken::new()).unwrap();
|
||||
let outputs = session.build(root, &model, root, false, None, &CancellationToken::new()).unwrap();
|
||||
assert!(outputs.fingerprint.is_none());
|
||||
assert_eq!(fs::read(cache.join("java-launch/records/main.json")).unwrap(), b"null");
|
||||
assert!(outputs.snapshot.join("0/Main.class").is_file());
|
||||
@@ -474,14 +381,14 @@ mod tests {
|
||||
let cache = root.join("cache");
|
||||
let cancel = CancellationToken::new();
|
||||
let mut session = Session::new(cache.clone());
|
||||
let first = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
fs::remove_file(root.join("src/main/kotlin/Main.kt")).unwrap();
|
||||
assert!(!compile::launch_has_non_java_sources(root, &model, root, false));
|
||||
assert!(has_record(root, &cache, root));
|
||||
assert!(session.build(root, &model, root, BuildOptions::default(), None, &cancel).is_err());
|
||||
assert!(session.build(root, &model, root, false, None, &cancel).is_err());
|
||||
assert!(has_record(root, &cache, root));
|
||||
fs::write(root.join("src/main/kotlin/Main.kt"), "restored").unwrap();
|
||||
let restored = session.build(root, &model, root, BuildOptions::default(), None, &cancel).unwrap();
|
||||
let restored = session.build(root, &model, root, false, None, &cancel).unwrap();
|
||||
assert_ne!(first.snapshot, restored.snapshot);
|
||||
}
|
||||
}
|
||||
+17
-408
@@ -56,7 +56,7 @@ pub async fn lsp_completion(
|
||||
}
|
||||
|
||||
/// The plugin host and the document, when some plugin provides `kind`.
|
||||
pub(crate) fn plugin_providers(
|
||||
fn plugin_providers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
@@ -74,48 +74,10 @@ pub(crate) fn plugin_providers(
|
||||
}
|
||||
|
||||
/// Plugin calls block (the plugin's actor thread does the work).
|
||||
pub(crate) async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
async fn plugin_call<T: Send + 'static>(f: impl FnOnce() -> T + Send + 'static) -> Option<T> {
|
||||
tokio::task::spawn_blocking(f).await.ok()
|
||||
}
|
||||
|
||||
fn provider_server<T: Default>(server: IdeResult<T>) -> IdeResult<T> {
|
||||
match server {
|
||||
Ok(v) => Ok(v),
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Ok(T::default()),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_language_locations(mut server: Vec<Location>, plugin: Vec<ide_plugin::Location>) -> Vec<Location> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().cloned().collect();
|
||||
for l in plugin {
|
||||
let loc = Location::file(l.path, l.range);
|
||||
if seen.insert(loc.clone()) {
|
||||
server.push(loc);
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
fn merge_language_highlights(
|
||||
mut server: Vec<Highlight>,
|
||||
plugin: Vec<ide_plugin::language_features::Highlight>,
|
||||
) -> Vec<Highlight> {
|
||||
let mut seen: std::collections::HashSet<_> = server.iter().map(|h| h.range).collect();
|
||||
for h in plugin {
|
||||
if seen.insert(h.range) {
|
||||
server.push(Highlight {
|
||||
range: h.range,
|
||||
kind: match h.kind.as_str() {
|
||||
"read" => HighlightKind::Read,
|
||||
"write" => HighlightKind::Write,
|
||||
_ => HighlightKind::Text,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
server
|
||||
}
|
||||
|
||||
/// Items from plugins carry no server: commands don't apply, and a code
|
||||
/// action is resolved by the plugin named in its `data` ([`PluginActionRef`]).
|
||||
const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
@@ -260,21 +222,7 @@ pub async fn lsp_hover(
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
|
||||
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
|
||||
return server;
|
||||
}
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::SignatureHelp) else {
|
||||
return server;
|
||||
};
|
||||
let help = plugin_call(move || plugins.language_signature_help(&d, pos)).await.flatten();
|
||||
match help {
|
||||
Some(h) => {
|
||||
let _ = provider_server(server)?;
|
||||
Ok(Some(crate::plugins::language_features::signature_help(h)))
|
||||
}
|
||||
None => server,
|
||||
}
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
@@ -290,23 +238,6 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
|
||||
let k =
|
||||
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, k) else {
|
||||
return result;
|
||||
};
|
||||
let locations = plugin_call(move || {
|
||||
if kind == GotoKind::Implementation {
|
||||
plugins.language_implementation(&d, pos)
|
||||
} else {
|
||||
plugins.language_type_definition(&d, pos)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, locations));
|
||||
}
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
}
|
||||
@@ -340,26 +271,6 @@ fn string_at(structure: &ide_syntax::StructureService, snap: &ide_syntax::Snapsh
|
||||
stringy(&at.node.kind) || (part(&at.node.kind) && at.ancestors.first().is_some_and(|parent| stringy(&parent.kind)))
|
||||
}
|
||||
|
||||
fn identifier_at(
|
||||
structure: &ide_syntax::StructureService,
|
||||
snap: &ide_syntax::Snapshot,
|
||||
range: Range,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> bool {
|
||||
let Ok(Some(at)) = structure.node_at(snap, range, cancel) else { return false };
|
||||
at.node.kind.contains("identifier")
|
||||
|| matches!(
|
||||
at.node.kind.as_str(),
|
||||
"name"
|
||||
| "constant"
|
||||
| "label_name"
|
||||
| "variable_name"
|
||||
| "instance_variable"
|
||||
| "class_variable"
|
||||
| "global_variable"
|
||||
)
|
||||
}
|
||||
|
||||
fn definition_targets(
|
||||
server: IdeResult<Vec<Location>>,
|
||||
plugin: Vec<Location>,
|
||||
@@ -427,20 +338,6 @@ pub async fn lsp_implementations(
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let count = positions.len();
|
||||
let found = plugin_call(move || {
|
||||
positions.into_iter().map(|pos| plugins.language_implementation(&d, pos)).collect::<Vec<_>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let mut out = provider_server(result)?;
|
||||
out.resize_with(count, Vec::new);
|
||||
for (items, found) in out.iter_mut().zip(found) {
|
||||
*items = merge_language_locations(std::mem::take(items), found);
|
||||
}
|
||||
return Ok(out);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -463,10 +360,6 @@ pub async fn lsp_goto_implementation(
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
|
||||
let found = plugin_call(move || plugins.language_implementation(&d, pos)).await.unwrap_or_default();
|
||||
return Ok(merge_language_locations(provider_server(result)?, found));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
@@ -487,8 +380,8 @@ pub async fn lsp_resolve_class_file(host: &Host, window: &str, path: std::path::
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Java: without `libraries`, the dependencies and the JDK are not
|
||||
/// searched; with it and `project_wide`, those found in the sources arrive on `on_partial` before
|
||||
/// leave it off. Java, `project_wide`: without `libraries`, the dependencies and the JDK
|
||||
/// are not searched; with it, those found in the sources arrive on `on_partial` before
|
||||
/// the slower search for the rest, and the result has them all. Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -592,11 +485,10 @@ fn validate_indexed_symbol(
|
||||
let stale = || {
|
||||
ide_core::IdeError::new(ErrorKind::OutOfSync, "indexed document changed; wait for indexing and try again")
|
||||
};
|
||||
let path = dunce::simplified(&file.path);
|
||||
if ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()).ok().as_deref() != Some(path) {
|
||||
if ide_plugin::resolve_in_root(&project.root, &file.path.to_string_lossy()).ok().as_ref() != Some(&file.path) {
|
||||
return Err(stale());
|
||||
}
|
||||
let text = if let Some(doc) = project.documents.by_path(path) {
|
||||
let text = if let Some(doc) = project.documents.by_path(&file.path) {
|
||||
let snapshot = project.documents.text_snapshot(doc)?;
|
||||
if snapshot.large || file.version.is_some_and(|v| v != snapshot.version) {
|
||||
return Err(stale());
|
||||
@@ -605,7 +497,7 @@ fn validate_indexed_symbol(
|
||||
} else {
|
||||
let loaded = project
|
||||
.documents
|
||||
.load_file_bounded(path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.load_file_bounded(&file.path, crate::plugins::navigation::MAX_DOCUMENT_BYTES)
|
||||
.map_err(|_| stale())?;
|
||||
if loaded.binary || loaded.large {
|
||||
return Err(stale());
|
||||
@@ -643,12 +535,7 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_document_highlights(&d, pos)).await.unwrap_or_default();
|
||||
Ok(merge_language_highlights(provider_server(server)?, items))
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
@@ -690,7 +577,7 @@ pub async fn lsp_code_actions(
|
||||
let actions = if plugin_actions.is_empty() {
|
||||
result?
|
||||
} else {
|
||||
let mut out = provider_server(result)?;
|
||||
let mut out = result.unwrap_or_default();
|
||||
out.extend(plugin_actions);
|
||||
out
|
||||
};
|
||||
@@ -901,12 +788,6 @@ async fn is_client_follow_up(
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
if let Some(symbol) = indexed_symbol(host, window, doc, pos).await? {
|
||||
let project = host.project(window)?;
|
||||
if let Some(refusal) = indexed_code_identifier(host, window, doc, symbol.range, None).await?
|
||||
&& let Err(e) = project.lsp.prepare_rename(doc, pos).await
|
||||
&& indexed_lsp_error(e)?.is_none()
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
return blocking(move || {
|
||||
validate_indexed_symbol(&project, &symbol, None)?;
|
||||
Ok(Some(symbol.range))
|
||||
@@ -1002,15 +883,7 @@ pub async fn lsp_rename(
|
||||
let mut renamed = match result {
|
||||
Ok(renamed) => renamed,
|
||||
Err(e) if symbol.is_some() && !native_owned => {
|
||||
let incomplete = indexed_lsp_error(e)?;
|
||||
if incomplete.is_none()
|
||||
&& let Some(symbol) = &symbol
|
||||
&& let Some(refusal) =
|
||||
indexed_code_identifier(host, window, doc, symbol.range, Some(cancel.clone())).await?
|
||||
{
|
||||
return Err(refusal);
|
||||
}
|
||||
Renamed { incomplete, ..Default::default() }
|
||||
Renamed { incomplete: indexed_lsp_error(e)?, ..Default::default() }
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
@@ -1048,39 +921,6 @@ pub async fn lsp_rename(
|
||||
result
|
||||
}
|
||||
|
||||
async fn indexed_code_identifier(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
cancel: Option<ide_core::CancellationToken>,
|
||||
) -> IdeResult<Option<ide_core::IdeError>> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = cancel.unwrap_or_default();
|
||||
blocking(move || {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let s = ide_syntax::Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
let identifier = identifier_at(&project.structure, &s, range, &cancel);
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if !identifier {
|
||||
return Ok(None);
|
||||
}
|
||||
let language = project.languages.get(&snap.language).map_or_else(|| snap.language.to_string(), |l| l.name);
|
||||
Ok(Some(ide_core::IdeError::invalid(format!(
|
||||
"The {language} language server can't rename this symbol; renaming only the plugin's indexed names would leave it half renamed"
|
||||
))))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
fn merge_indexed_rename(
|
||||
renamed: &mut Renamed,
|
||||
symbol: &crate::plugins::navigation::IndexedSymbol,
|
||||
@@ -1198,52 +1038,12 @@ pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeRe
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
let server = host.project(window)?.lsp.code_lens(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::CodeLenses) else {
|
||||
return server;
|
||||
};
|
||||
let items = plugin_call(move || plugins.language_code_lenses(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().map(|(plugin, i)| CodeLens {
|
||||
range: i.range,
|
||||
command: Some(Command {
|
||||
title: i.title,
|
||||
command: format!("openidea.plugin-language:{plugin}:{}", i.command),
|
||||
arguments: vec![serde_json::json!(serde_json::to_string(&i.argument).unwrap_or_default())],
|
||||
}),
|
||||
}));
|
||||
Ok(out)
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
let server = host.project(window)?.lsp.document_links(doc).await;
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentLinks) else {
|
||||
return server;
|
||||
};
|
||||
let root = host.project(window)?.root.clone();
|
||||
let items = plugin_call(move || plugins.language_document_links(&d)).await.unwrap_or_default();
|
||||
let mut out = provider_server(server)?;
|
||||
out.extend(items.into_iter().filter_map(|i| {
|
||||
let url = i.target.starts_with("https://") || i.target.starts_with("http://");
|
||||
if !url && ide_core::is_virtual_uri(&i.target) {
|
||||
return None;
|
||||
}
|
||||
Some(DocumentLink {
|
||||
range: i.range,
|
||||
path: (!url).then(|| {
|
||||
{
|
||||
let path = std::path::PathBuf::from(&i.target);
|
||||
if path.is_absolute() { path } else { root.join(path) }
|
||||
}
|
||||
.into()
|
||||
}),
|
||||
url: url.then_some(i.target),
|
||||
fragment: None,
|
||||
tooltip: None,
|
||||
})
|
||||
}));
|
||||
Ok(out)
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
@@ -1333,34 +1133,6 @@ pub async fn redo_workspace_edit(host: &Host, window: &str, id: u32) -> IdeResul
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
if let Some(rest) = command.command.strip_prefix("openidea.plugin-language:") {
|
||||
let (plugin, command_id) =
|
||||
rest.split_once(':').ok_or_else(|| ide_core::IdeError::invalid("Invalid plugin command"))?;
|
||||
let plugins = crate::plugins::editor_plugin_host()
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("Plugin host unavailable"))?;
|
||||
let argument = command
|
||||
.arguments
|
||||
.first()
|
||||
.and_then(|a| a.as_str())
|
||||
.and_then(|s| serde_json::from_str::<Option<String>>(s).ok())
|
||||
.flatten();
|
||||
let project = host.project(window)?;
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
let invocation = ide_plugin::Invocation {
|
||||
client: Some(window.into()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: Some(ide_plugin::EditorContext {
|
||||
path: snap.path.0.clone(),
|
||||
language: snap.language.0,
|
||||
selections: vec![],
|
||||
}),
|
||||
selected_paths: vec![snap.path.0],
|
||||
..Default::default()
|
||||
};
|
||||
let (plugin, command_id) = (plugin.to_owned(), command_id.to_owned());
|
||||
return blocking(move || plugins.run_language_command(&plugin, &command_id, argument.as_deref(), invocation))
|
||||
.await;
|
||||
}
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
@@ -1423,74 +1195,7 @@ pub async fn lsp_execute_code_action(
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
/// Versioned plugin visuals, refreshed after the editor synchronizes its buffer.
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginLanguageVisuals {
|
||||
pub version: u64,
|
||||
pub injections: Vec<ide_plugin::language_features::Injection>,
|
||||
pub colors: Vec<(String, ide_plugin::language_features::ColorInfo)>,
|
||||
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
|
||||
}
|
||||
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
let mut out =
|
||||
PluginLanguageVisuals { version: d.version, injections: vec![], colors: vec![], gutter_actions: vec![] };
|
||||
if d.text.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
out.injections = plugins.provide_injections(&d);
|
||||
out.colors = plugins.language_colors(&d);
|
||||
out.gutter_actions = plugins.language_gutter_actions(&d);
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub async fn plugin_color_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
plugin: String,
|
||||
color: ide_plugin::language_features::ColorInfo,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let snap = host.project(window)?.documents.snapshot(doc)?;
|
||||
blocking(move || {
|
||||
let d = ProviderDocument {
|
||||
path: snap.path.0,
|
||||
language: snap.language.0,
|
||||
version: u64::from(snap.version),
|
||||
text: snap.text,
|
||||
};
|
||||
if !crate::plugins::language_features::valid_range(&d, color.range) {
|
||||
return Err(ide_core::IdeError::invalid("Invalid color range"));
|
||||
}
|
||||
Ok(crate::plugins::editor_plugin_host()
|
||||
.map(|p| p.language_color_presentations(&plugin, &d, color))
|
||||
.unwrap_or_default())
|
||||
})
|
||||
.await
|
||||
}
|
||||
pub fn plugin_formatters() -> Vec<crate::plugins::language_features::FormatterChoice> {
|
||||
crate::plugins::editor_plugin_host().map(|p| p.formatter_choices()).unwrap_or_default()
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add(
|
||||
"plugin_language_visuals",
|
||||
|c| async move { plugin_language_visuals(&c.host, &c.window, c.arg("doc")?).await },
|
||||
);
|
||||
d.add("plugin_color_presentations", |c| async move {
|
||||
plugin_color_presentations(&c.host, &c.window, c.arg("doc")?, c.arg("plugin")?, c.arg("color")?).await
|
||||
});
|
||||
d.add("plugin_formatters", |_c| async move { Ok(plugin_formatters()) });
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
@@ -1636,32 +1341,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn language_features_merge_without_overwriting_lsp_ranges_or_masking_errors() {
|
||||
use super::*;
|
||||
let range = Range::new(Pos::default(), Pos::new(0, 1));
|
||||
let existing = Location::file("/p/a".into(), range);
|
||||
let merged = merge_language_locations(
|
||||
vec![existing.clone()],
|
||||
vec![
|
||||
ide_plugin::Location { path: "/p/a".into(), range },
|
||||
ide_plugin::Location { path: "/p/b".into(), range },
|
||||
],
|
||||
);
|
||||
assert_eq!(merged.len(), 2);
|
||||
assert_eq!(merged[0], existing);
|
||||
let merged = merge_language_highlights(
|
||||
vec![Highlight { range, kind: HighlightKind::Read }],
|
||||
vec![ide_plugin::language_features::Highlight { range, kind: "write".into() }],
|
||||
);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].kind, HighlightKind::Read);
|
||||
assert!(
|
||||
provider_server::<Vec<Location>>(Err(ide_core::IdeError::unavailable("no server"))).unwrap().is_empty()
|
||||
);
|
||||
assert!(provider_server::<Vec<Location>>(Err(ide_core::IdeError::cancelled())).is_err());
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
@@ -1684,13 +1363,11 @@ mod tests {
|
||||
use crate::plugins::navigation::{IndexedAnchors, IndexedSymbol};
|
||||
use ide_core::{FileEdit, LanguageId};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let shared = root.join("shared.idx");
|
||||
let native = root.join("native.txt");
|
||||
std::fs::write(&shared, "old old").unwrap();
|
||||
std::fs::write(&native, "old").unwrap();
|
||||
// Exercise legacy verbatim index paths alongside ordinary native URI paths.
|
||||
let shared = std::fs::canonicalize(shared).unwrap();
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 3));
|
||||
let other = Range::new(Pos::new(0, 4), Pos::new(0, 7));
|
||||
let symbol = IndexedSymbol {
|
||||
@@ -1702,6 +1379,8 @@ mod tests {
|
||||
ranges: vec![range, other],
|
||||
}],
|
||||
};
|
||||
// Native file URIs have ordinary Windows paths; index snapshots retain
|
||||
// canonical verbatim paths. Both identify the same file and edit batch.
|
||||
let server_shared = dunce::simplified(&shared).to_path_buf();
|
||||
let server_native = dunce::simplified(&native).to_path_buf();
|
||||
#[cfg(windows)]
|
||||
@@ -1928,76 +1607,6 @@ mod tests {
|
||||
assert!(string_at(&structure, &snap, Pos::new(1, 53)), "inside \"users\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indexed_names_on_code_identifiers_are_told_apart_from_strings_and_comments() {
|
||||
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 cancel = ide_core::CancellationToken::new();
|
||||
let anchor = |text: &str, line: u32, name: &str| {
|
||||
let col = text.lines().nth(line as usize).unwrap().find(name).unwrap() as u32;
|
||||
Range::new(Pos::new(line, col), Pos::new(line, col + name.len() as u32))
|
||||
};
|
||||
for (language, text, name) in [
|
||||
("java", "class Order {}", "Order"),
|
||||
("kotlin", "class Order", "Order"),
|
||||
("typescript", "class Order {}", "Order"),
|
||||
("python", "class Order:\n pass", "Order"),
|
||||
("go", "package app\ntype Order struct {}", "Order"),
|
||||
("rust", "struct Order {}", "Order"),
|
||||
("cpp", "class Order {};", "Order"),
|
||||
("go", "package app\nfunc f() { retry: goto retry }", "retry"),
|
||||
("ruby", "@order = 1", "@order"),
|
||||
("ruby", "@@order = 1", "@@order"),
|
||||
("ruby", "$order = 1", "$order"),
|
||||
("php", "<?php $order = 1;", "$order"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
for (language, text, name) in [
|
||||
("java", "class App { String route = \"orders.index\"; }", "orders.index"),
|
||||
("kotlin", "val route = \"orders.index\"", "orders.index"),
|
||||
("typescript", "const route = \"orders.index\";", "orders.index"),
|
||||
("python", "route = \"orders.index\"", "orders.index"),
|
||||
("go", "package app\nvar route = \"orders.index\"", "orders.index"),
|
||||
("rust", "const ROUTE: &str = \"orders.index\";", "orders.index"),
|
||||
("cpp", "const char* route = \"orders.index\";", "orders.index"),
|
||||
("json", "{\"orders.index\": \"orders.index\"}", "orders.index"),
|
||||
("yaml", "orders.index: orders.index", "orders.index"),
|
||||
("xml", "<select id=\"orders.index\" />", "id"),
|
||||
] {
|
||||
let language = ide_core::LanguageId::new(language);
|
||||
let snap = ide_syntax::Snapshot::text(&language, text);
|
||||
let line = u32::from(text.starts_with("package"));
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, line, name), &cancel), "{language}: {name}");
|
||||
}
|
||||
let php = ide_core::LanguageId::new("php");
|
||||
let text = "<?php\nuse App\\Models\\Order;\nclass Order {}\n// Order is a model\n\
|
||||
return route('users.show', Order::query());\n";
|
||||
let snap = ide_syntax::Snapshot::text(&php, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 1, "Order"), &cancel), "an imported class");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a class declaration");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 4, "Order"), &cancel), "a static call's class");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 4, "users.show"), &cancel), "a route name");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 3, "Order"), &cancel), "a comment");
|
||||
let ruby = ide_core::LanguageId::new("ruby");
|
||||
let text = "class Order\nend\nOrder.where(status: \"paid\")\n";
|
||||
let snap = ide_syntax::Snapshot::text(&ruby, text);
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 0, "Order"), &cancel), "a Ruby class name");
|
||||
assert!(identifier_at(&structure, &snap, anchor(text, 2, "Order"), &cancel), "a Ruby constant reference");
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 2, "paid"), &cancel), "a Ruby string");
|
||||
let xml = ide_core::LanguageId::new("xml");
|
||||
let text = "<mapper>\n <select id=\"selectById\">select 1</select>\n</mapper>\n";
|
||||
let snap = ide_syntax::Snapshot::text(&xml, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 1, "selectById"), &cancel), "a MyBatis statement id");
|
||||
let idx = ide_core::LanguageId::new("idx");
|
||||
let text = "def route\n";
|
||||
let snap = ide_syntax::Snapshot::text(&idx, text);
|
||||
assert!(!identifier_at(&structure, &snap, anchor(text, 0, "route"), &cancel), "a language without a grammar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generated_ide_helpers_give_way_to_real_declarations() {
|
||||
let at = |path: &str| Location { path: path.into(), range: Range::default(), uri: None };
|
||||
@@ -2025,7 +1634,7 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = dunce::canonicalize(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
|
||||
@@ -9,9 +9,6 @@ use std::time::Duration;
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.refresh_run_configurations(&project.root);
|
||||
}
|
||||
project_configs(&project.root, &project.settings.effective(), &host.config_dir)
|
||||
}
|
||||
|
||||
@@ -202,48 +199,8 @@ pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> Ide
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
let operation = config.extras.params.get("plugin.operation").cloned();
|
||||
let result = start_inner(host, window, config, on_output).await;
|
||||
if let (Ok(info), Some(operation), Some(plugins)) = (&result, operation, host.plugin_host()) {
|
||||
plugins.run_operation_started(window, &operation, info.id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
async fn start_inner(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut config: RunConfig,
|
||||
on_output: Emit<OutputChunk>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = ide_sdk::apply::run_target(&config, &project.settings.effective()).map(str::to_owned);
|
||||
}
|
||||
if config.detected
|
||||
&& config.id.starts_with(ide_run::EXTERNAL_PREFIX)
|
||||
&& let Some(plugins) = host.plugin_host()
|
||||
{
|
||||
let provider = plugins.run_detection();
|
||||
let binary =
|
||||
provider.launch(&config).map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
provider
|
||||
.detect(&project.root)
|
||||
.into_iter()
|
||||
.find(|c| c.id == config.id)
|
||||
.ok_or_else(|| IdeError::not_found("plugin configuration no longer available"))?
|
||||
.command
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
}
|
||||
let mut sink = project.sink.clone();
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
sink = plugins.run_operation_sink(window, &mut config, sink)?;
|
||||
}
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
@@ -253,25 +210,6 @@ async fn start_inner(
|
||||
}
|
||||
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
if id.starts_with("plugin-target:") {
|
||||
let plugins =
|
||||
host.plugin_host().ok_or_else(|| IdeError::not_found("plugin run target is no longer available"))?;
|
||||
let root = project.root.clone();
|
||||
let id = id.to_owned();
|
||||
let config = host.blocking(move || plugins.prepare_run_target(&root, &id, &config)).await?;
|
||||
project.require_trust()?;
|
||||
return host.processes.start(
|
||||
&project.root,
|
||||
&config,
|
||||
&StartOptions {
|
||||
encoding: Some(settings.console.encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: Vec::new(),
|
||||
ruby_reporter: None,
|
||||
},
|
||||
sink,
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let target = ide_remote::service::run_target(&settings, id)?;
|
||||
if target.kind != RunTargetKind::Ssh {
|
||||
// A local run of the docker CLI: the command is resolved in the container, and
|
||||
@@ -289,12 +227,15 @@ async fn start_inner(
|
||||
&project.root,
|
||||
&config,
|
||||
&options,
|
||||
sink.clone(),
|
||||
project.sink.clone(),
|
||||
Box::new(move |c| on_output(c)),
|
||||
);
|
||||
}
|
||||
let out = on_output.clone();
|
||||
return host.remote.run(&settings, &project.root, &config, sink.clone(), Box::new(move |c| out(c))).await;
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let mut config = config;
|
||||
let (root, settings) = settings_for(host, &project, &mut config)?;
|
||||
@@ -345,6 +286,7 @@ async fn start_inner(
|
||||
ruby_reporter: ruby_reporter_dir(host, &config),
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
@@ -375,9 +317,6 @@ fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathB
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
if let Some(plugins) = host.plugin_host() {
|
||||
plugins.run_operation_stopped(id);
|
||||
}
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
|
||||
@@ -207,13 +207,7 @@ pub async fn sdk_project_libraries(
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let groups =
|
||||
ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat());
|
||||
if host.project(window).is_ok_and(|current| std::sync::Arc::ptr_eq(¤t, &project)) {
|
||||
host.mcp_reports
|
||||
.set_libraries(window, groups.iter().flat_map(|g| g.roots.iter().map(|r| r.path.0.clone())).collect());
|
||||
}
|
||||
Ok(groups)
|
||||
Ok(ide_sdk::libraries::merge_groups([libraries.map_err(join_err)?, dependencies.map_err(join_err)?].concat()))
|
||||
}
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
|
||||
@@ -34,12 +34,67 @@ pub async fn start(
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let events = on_event.clone();
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
search_project(&project, &syntax, &query, &cancel, &|file| events(SearchEvent::File { file }))
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
events(SearchEvent::File { file });
|
||||
},
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
@@ -47,69 +102,5 @@ pub async fn start(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shared Find in Files engine: project excludes are added by the caller.
|
||||
pub(crate) fn search_project(
|
||||
project: &crate::project::Project,
|
||||
syntax: &ide_language::Syntax,
|
||||
query: &SearchQuery,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
emit: &(dyn Fn(FileMatches) + Sync),
|
||||
) -> IdeResult<SearchSummary> {
|
||||
let root = &project.root;
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some();
|
||||
let include_root = !workspace
|
||||
|| matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. });
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project
|
||||
| ide_search::SearchScope::FileSet { .. }
|
||||
| ide_search::SearchScope::Pattern { .. } => project.content_roots(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
_ => true,
|
||||
};
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = project.languages.detect(path, text.lines().next());
|
||||
Some(syntax.literals(&language, text)?.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(root).filter(|_| include_root).chain(&content_roots) {
|
||||
let mut remaining = query.clone();
|
||||
let max = if query.max_matches == 0 { 10_000 } else { query.max_matches };
|
||||
remaining.max_matches = max.saturating_sub(total.matches).max(1);
|
||||
let summary = ide_search::search_in_context(
|
||||
dir,
|
||||
&remaining,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| project.documents.search_text_of(p),
|
||||
cancel,
|
||||
&|mut file| {
|
||||
if dir != root {
|
||||
let name = dir.file_name().unwrap_or_default().to_string_lossy();
|
||||
file.rel_path = format!("{name}/{}", file.rel_path);
|
||||
}
|
||||
emit(file);
|
||||
},
|
||||
)?;
|
||||
total.files += summary.files;
|
||||
total.matches += summary.matches;
|
||||
total.elapsed_ms += summary.elapsed_ms;
|
||||
total.limit_hit |= summary.limit_hit || total.matches >= max;
|
||||
total.cancelled |= summary.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -24,37 +24,3 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
fn check_worktree_removal(project_root: &std::path::Path, path: &std::path::Path) -> IdeResult<()> {
|
||||
fn canonical_path(path: &std::path::Path) -> std::io::Result<std::path::PathBuf> {
|
||||
let mut existing = path.to_path_buf();
|
||||
let mut tail = Vec::new();
|
||||
let mut real = loop {
|
||||
match dunce::canonicalize(&existing) {
|
||||
Ok(real) => break real,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let Some(part) = existing.components().next_back() else { return Err(e) };
|
||||
tail.push(part.as_os_str().to_owned());
|
||||
if !existing.pop() {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
};
|
||||
// The project or worktree may already be gone; resolve its missing tail too.
|
||||
for part in tail.into_iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
if canonical_path(project_root)?.starts_with(canonical_path(path)?) {
|
||||
return Err(ide_core::IdeError::invalid("Cannot remove a worktree containing the open project."));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1041,12 +1041,6 @@ pub async fn vcs_remove_worktree(
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let path = if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
project.with_repo_at(repo.as_deref(), |r| Ok(r.root().join(&path)))?
|
||||
};
|
||||
check_worktree_removal(&project.root, &path)?;
|
||||
let result = project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force));
|
||||
project.refresh_repositories();
|
||||
result
|
||||
|
||||
+29
-1373
File diff suppressed because it is too large.
Load diff
@@ -1,205 +0,0 @@
|
||||
//! One-shot OAuth redirects, always bound on the desktop's IPv4 loopback.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{Ipv4Addr, TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) trait Service: Send + Sync {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError>;
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError>;
|
||||
fn close(&self, plugin: &str, id: u64);
|
||||
fn clear(&self, plugin: &str);
|
||||
fn forget_window(&self, window: &str);
|
||||
}
|
||||
struct Entry {
|
||||
window: String,
|
||||
path: String,
|
||||
listener: Mutex<Option<TcpListener>>,
|
||||
cancel: CancellationToken,
|
||||
}
|
||||
#[derive(Default)]
|
||||
pub(super) struct Loopbacks {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Entry>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
fn respond(stream: &mut TcpStream, ok: bool) {
|
||||
let body = if ok { "You can close this tab." } else { "Not found." };
|
||||
let status = if ok { "200 OK" } else { "404 Not Found" };
|
||||
let _ = write!(
|
||||
stream,
|
||||
"HTTP/1.1 {status}\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\nCache-Control: no-store\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
}
|
||||
impl Loopbacks {
|
||||
fn remove_where(&self, predicate: impl Fn(&str, &Entry) -> bool) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if !predicate(p, entry) {
|
||||
return true;
|
||||
}
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
impl Service for Loopbacks {
|
||||
fn begin(&self, plugin: &str, window: &str, path: &str) -> Result<(u64, String), HostError> {
|
||||
let path = path.trim_start_matches('/');
|
||||
if path.len() > 1024
|
||||
|| path.bytes().any(|b| !(b.is_ascii_alphanumeric() || b"/-._~".contains(&b)))
|
||||
|| path.split('/').any(|s| matches!(s, "." | ".."))
|
||||
{
|
||||
return Err(HostError::Invalid("loopback path must be a URL path without query or fragment".into()));
|
||||
}
|
||||
let mut entries = self.entries.lock();
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 2 {
|
||||
return Err(HostError::Invalid("at most two open loopbacks per plugin".into()));
|
||||
}
|
||||
let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).map_err(failed)?;
|
||||
listener.set_nonblocking(true).map_err(failed)?;
|
||||
let uri = format!("http://127.0.0.1:{}/{path}", listener.local_addr().map_err(failed)?.port());
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Entry {
|
||||
window: window.into(),
|
||||
path: format!("/{path}"),
|
||||
listener: Mutex::new(Some(listener)),
|
||||
cancel: CancellationToken::new(),
|
||||
}),
|
||||
);
|
||||
Ok((id, uri))
|
||||
}
|
||||
fn wait(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let entry = self
|
||||
.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("loopback".into()))?;
|
||||
let listener =
|
||||
entry.listener.lock().take().ok_or_else(|| HostError::Invalid("loopback already awaited".into()))?;
|
||||
let deadline = Instant::now() + timeout;
|
||||
let result = (|| loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
match listener.accept() {
|
||||
Ok((mut stream, address)) => {
|
||||
if !address.ip().is_loopback() {
|
||||
continue;
|
||||
}
|
||||
stream.set_nonblocking(true).map_err(failed)?;
|
||||
let mut head = Vec::new();
|
||||
loop {
|
||||
if cancel.is_cancelled() || entry.cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buf = [0; 1024];
|
||||
match stream.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
head.extend_from_slice(&buf[..n]);
|
||||
if head.windows(4).any(|w| w == b"\r\n\r\n") || head.len() > 8192 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
std::thread::sleep(Duration::from_millis(5))
|
||||
}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
}
|
||||
let line = std::str::from_utf8(&head).unwrap_or_default().lines().next().unwrap_or_default();
|
||||
let target = line.strip_prefix("GET ").and_then(|s| s.split_once(' ')).map(|(target, _)| target);
|
||||
let query = target.and_then(|target| {
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
(path == entry.path && head.len() <= 8192).then(|| query.to_owned())
|
||||
});
|
||||
stream.set_nonblocking(false).map_err(failed)?;
|
||||
stream.set_write_timeout(Some(Duration::from_millis(100))).map_err(failed)?;
|
||||
respond(&mut stream, query.is_some());
|
||||
if query.is_some() {
|
||||
return Ok(query);
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(e) => return Err(failed(e)),
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Ok(None);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
})();
|
||||
self.close(plugin, id);
|
||||
result
|
||||
}
|
||||
fn close(&self, plugin: &str, id: u64) {
|
||||
let entry = self.entries.lock().remove(&(plugin.into(), id));
|
||||
if let Some(entry) = entry {
|
||||
entry.cancel.cancel();
|
||||
entry.listener.lock().take();
|
||||
}
|
||||
}
|
||||
fn clear(&self, plugin: &str) {
|
||||
self.remove_where(|p, _| p == plugin || p.starts_with("remote:") && p.ends_with(&format!(":{plugin}")));
|
||||
}
|
||||
fn forget_window(&self, window: &str) {
|
||||
self.remove_where(|_, entry| entry.window == window);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn one_shot_raw_query_limits_timeout_and_cleanup() {
|
||||
let service = Arc::new(Loopbacks::default());
|
||||
let (id, uri) = service.begin("a", "main", "oauth/callback").unwrap();
|
||||
assert!(uri.starts_with("http://127.0.0.1:"));
|
||||
let (other, _) = service.begin("a", "main", "").unwrap();
|
||||
assert!(service.begin("a", "main", "third").is_err());
|
||||
assert_eq!(service.wait("a", other, Duration::ZERO, &CancellationToken::new()).unwrap(), None);
|
||||
let host = uri.strip_prefix("http://").unwrap().split('/').next().unwrap().to_owned();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(host).unwrap();
|
||||
stream.write_all(b"GET /oauth/callback?code=a%2Bb&state=x HTTP/1.1\r\nHost: localhost\r\n\r\n").unwrap();
|
||||
let mut answer = String::new();
|
||||
stream.read_to_string(&mut answer).unwrap();
|
||||
assert!(answer.contains("You can close this tab"));
|
||||
});
|
||||
assert_eq!(
|
||||
service.wait("a", id, Duration::from_secs(2), &CancellationToken::new()).unwrap(),
|
||||
Some("code=a%2Bb&state=x".into())
|
||||
);
|
||||
worker.join().unwrap();
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
let (id, _) = service.begin("a", "main", "callback").unwrap();
|
||||
service.forget_window("main");
|
||||
assert!(service.wait("a", id, Duration::ZERO, &CancellationToken::new()).is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
//! Database ownership stays on the project host; credentials never pass back to guests.
|
||||
use super::api::PluginApi;
|
||||
use super::{PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult};
|
||||
use ide_db::{DataSource, DbDriver, DbProperty};
|
||||
use ide_plugin::{HostError, PluginDataSource};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct OwnedProject {
|
||||
root: PathBuf,
|
||||
sources: std::collections::BTreeSet<String>,
|
||||
}
|
||||
|
||||
pub(super) fn settings(source: PluginDataSource) -> Result<DataSource, HostError> {
|
||||
let bad = || HostError::Invalid("invalid non-secret database source settings".into());
|
||||
if source.id.is_empty()
|
||||
|| source.id.len() > 128
|
||||
|| source.name.trim().is_empty()
|
||||
|| source.name.len() > 256
|
||||
|| source.id.chars().chain(source.name.chars()).any(char::is_control)
|
||||
|| source.options.len() > 128
|
||||
|| [&source.host, &source.database, &source.user, &source.url]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.any(|v| v.len() > 16_384 || v.contains('\0'))
|
||||
{
|
||||
return Err(bad());
|
||||
}
|
||||
let driver = match source.driver.as_str() {
|
||||
"sqlite" => DbDriver::Sqlite,
|
||||
"postgres" => DbDriver::Postgres,
|
||||
"mysql" => DbDriver::Mysql,
|
||||
"sqlServer" => DbDriver::SqlServer,
|
||||
"clickhouse" => DbDriver::Clickhouse,
|
||||
"oracle" => DbDriver::Oracle,
|
||||
_ => return Err(bad()),
|
||||
};
|
||||
let secret = |key: &str| {
|
||||
let key = key.to_ascii_lowercase();
|
||||
["password", "passwd", "pwd", "token", "secret", "credential"].iter().any(|s| key.contains(s))
|
||||
};
|
||||
if source.options.iter().any(|(k, v)| {
|
||||
k.is_empty() || k.len() > 256 || v.len() > 16_384 || k.contains('\0') || v.contains('\0') || secret(k)
|
||||
}) {
|
||||
return Err(bad());
|
||||
}
|
||||
let mut out = DataSource {
|
||||
name: source.name,
|
||||
driver,
|
||||
host: source.host.unwrap_or_else(|| "localhost".into()),
|
||||
port: source.port,
|
||||
database: source.database.unwrap_or_default(),
|
||||
user: source.user.unwrap_or_default(),
|
||||
save_password: true,
|
||||
properties: source.options.into_iter().map(|(name, value)| DbProperty { name, value }).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
out.file = Some(out.database.clone().into());
|
||||
}
|
||||
if let Some(url) = source.url.filter(|u| !u.is_empty()) {
|
||||
if url.contains(';') {
|
||||
return Err(bad());
|
||||
}
|
||||
let parsed = ide_db::source::parse_url(&url).map_err(|_| bad())?;
|
||||
if parsed.password.is_some() || parsed.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
// Case-insensitive URL credential keys must not bypass the parser's lowercase password key.
|
||||
if let Some((_, query)) = url.split_once('?') {
|
||||
for part in query.split('&') {
|
||||
let key = part.split('=').next().unwrap_or_default();
|
||||
// Parsing a synthetic URL decodes percent-encoded parameter names as well.
|
||||
let probe = ide_db::source::parse_url(&format!("postgres://host/db?{key}=x")).map_err(|_| bad())?;
|
||||
if probe.password.is_some() || probe.params.iter().any(|(k, _)| secret(k)) {
|
||||
return Err(bad());
|
||||
}
|
||||
}
|
||||
}
|
||||
out.url_only = true;
|
||||
out.url = url;
|
||||
out.resolved().map_err(|_| bad())?;
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn database_host(&self, root: &Path) -> Result<(Arc<crate::Host>, Arc<ide_db::DbManager>), HostError> {
|
||||
let workspace = self.shared.workspace().ok_or(HostError::Cancelled)?;
|
||||
if !workspace.projects().iter().any(|r| r == root) {
|
||||
return Err(HostError::NotFound("project is not open".into()));
|
||||
}
|
||||
if !workspace.trusted(root) {
|
||||
return Err(HostError::Denied("project is not trusted".into()));
|
||||
}
|
||||
ide_plugin::resolve_new_in_root(root, ".openidea/data-sources.toml").map_err(HostError::Invalid)?;
|
||||
let host = workspace
|
||||
.database_host()
|
||||
.ok_or_else(|| HostError::Invalid("Database subsystem is unavailable on this host".into()))?;
|
||||
let db = host.projects().into_iter().find(|p| p.root == root).ok_or(HostError::Cancelled)?.db.clone();
|
||||
Ok((host, db))
|
||||
}
|
||||
pub(super) fn remember_database_project(&self, root: &Path, id: &str) -> Result<(), HostError> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{}.json", self.pkg.id()));
|
||||
let mut projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => vec![],
|
||||
Err(e) => return Err(HostError::Failed(e.to_string())),
|
||||
};
|
||||
if !projects.iter().any(|p| p.root == root) {
|
||||
projects.push(OwnedProject { root: root.into(), sources: Default::default() });
|
||||
}
|
||||
let project = projects.iter_mut().find(|p| p.root == root).unwrap();
|
||||
if project.sources.insert(id.into()) {
|
||||
std::fs::create_dir_all(file.parent().unwrap()).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
ide_vfs::write_atomic(&file, &serde_json::to_vec(&projects).unwrap()).map_err(error)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn connect_database(
|
||||
&self,
|
||||
root: &Path,
|
||||
client: &str,
|
||||
id: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let (_, db) = self.database_host(root)?;
|
||||
if self.shared.project_of(client).as_deref() != Some(root) {
|
||||
return Err(HostError::Invalid("requires this project's invoking window".into()));
|
||||
}
|
||||
let source = db.plugin_source(self.pkg.id(), id).map_err(error)?;
|
||||
self.window_request(client, WindowRequest::DatabaseConnect { id: source.id }, cancel, timeout).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn changed(host: &crate::Host, root: &Path) {
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
project.sink.emit(IdeEvent::DbSourcesChanged);
|
||||
}
|
||||
}
|
||||
impl PluginHost {
|
||||
pub(super) fn remove_database_sources(&self, plugin: &str) -> IdeResult<()> {
|
||||
let file = self.shared.data_dir.join(".database-projects").join(format!("{plugin}.json"));
|
||||
let projects: Vec<OwnedProject> = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string()))?,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let host = self
|
||||
.shared
|
||||
.workspace()
|
||||
.and_then(|w| w.database_host())
|
||||
.ok_or_else(|| IdeError::unavailable("Database subsystem is unavailable on this host"))?;
|
||||
for project in projects {
|
||||
let root = project.root;
|
||||
let removed = ide_db::plugin::remove_all(&root, plugin)?;
|
||||
let store = ide_db::secrets::SecretStore::new(host.data_dir.join("db-passwords.json"), &root);
|
||||
let ids: std::collections::BTreeSet<_> = project
|
||||
.sources
|
||||
.iter()
|
||||
.map(|id| ide_db::plugin::source_id(plugin, id))
|
||||
.chain(removed.into_iter().map(|source| source.id))
|
||||
.collect();
|
||||
for id in ids {
|
||||
store.delete(&id);
|
||||
store.delete(&ide_db::secrets::ssh_key(&id));
|
||||
for project in host.projects().into_iter().filter(|p| p.root == root) {
|
||||
host.runtime.block_on(project.db.forget_plugin_source(&id));
|
||||
}
|
||||
}
|
||||
changed(&host, &root);
|
||||
}
|
||||
std::fs::remove_file(file)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn error(e: IdeError) -> HostError {
|
||||
use ide_core::error::ErrorKind;
|
||||
match e.kind {
|
||||
ErrorKind::NotFound => HostError::NotFound(e.message),
|
||||
ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
ErrorKind::PermissionDenied => HostError::Denied(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn source() -> PluginDataSource {
|
||||
PluginDataSource { id: "local".into(), name: "Local".into(), driver: "postgres".into(), ..Default::default() }
|
||||
}
|
||||
#[test]
|
||||
fn rejects_secrets_without_echoing_them_and_documents_every_driver_id() {
|
||||
for driver in ["sqlite", "postgres", "mysql", "sqlServer", "clickhouse", "oracle"] {
|
||||
assert!(settings(PluginDataSource { driver: driver.into(), ..source() }).is_ok(), "{driver}");
|
||||
}
|
||||
for url in [
|
||||
"postgres://user:top-secret@host/db",
|
||||
"postgres://host/db?password=top-secret",
|
||||
"postgres://host/db?PASSWORD=top-secret",
|
||||
"postgres://host/db?to%6ben=top-secret",
|
||||
] {
|
||||
let error = settings(PluginDataSource { url: Some(url.into()), ..source() }).unwrap_err();
|
||||
assert!(!error.to_string().contains("top-secret"));
|
||||
}
|
||||
for key in ["password", "accessToken", "client_secret", "PWD"] {
|
||||
assert!(
|
||||
settings(PluginDataSource { options: vec![(key.into(), "top-secret".into())], ..source() }).is_err()
|
||||
);
|
||||
}
|
||||
let source = settings(PluginDataSource {
|
||||
url: Some("postgres://reader@host:5433/db?sslmode=require".into()),
|
||||
options: vec![("application_name".into(), "Example".into())],
|
||||
..source()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(source.resolved().unwrap().host, "host");
|
||||
}
|
||||
}
|
||||
@@ -79,9 +79,7 @@ impl Diagnostics {
|
||||
doc.cancel.cancel();
|
||||
doc.cancel = CancellationToken::new();
|
||||
let Some(host) = editor_plugin_host() else { return };
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty()
|
||||
&& !host.providers.any(ProviderKind::Injections)
|
||||
{
|
||||
if host.providers.plugins_for(ProviderKind::Diagnostics, &doc.snapshot.language).is_empty() {
|
||||
return;
|
||||
}
|
||||
// Register only once per project incarnation; no project is retained by the host.
|
||||
@@ -115,7 +113,6 @@ impl Diagnostics {
|
||||
snapshot.text = current.text;
|
||||
drop(documents);
|
||||
|
||||
let injections = host.providers.injection_generations(&snapshot.language);
|
||||
let generations = host.providers.diagnostic_generations(&snapshot.language);
|
||||
let results = if snapshot.text.len() <= super::navigation::MAX_DOCUMENT_BYTES {
|
||||
host.provide_project_diagnostics(Some(&root), &snapshot, &cancel)
|
||||
@@ -127,11 +124,6 @@ impl Diagnostics {
|
||||
if state.closed || cancel.is_cancelled() {
|
||||
return;
|
||||
}
|
||||
if injections != host.providers.injection_generations(&snapshot.language)
|
||||
|| injections.keys().any(|id| lifecycle_generations.get(id) != state.generations.get(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
let matching: HashSet<_> = host
|
||||
.diagnostic_plugins(Some(&root), &snapshot.language)
|
||||
.into_iter()
|
||||
@@ -232,13 +224,6 @@ pub(super) type ProjectState = (Weak<Mutex<State>>, SharedSink);
|
||||
impl PluginHost {
|
||||
pub(super) fn clear_provider_diagnostics(&self, plugin: &str) {
|
||||
// Clone before taking a project's state lock (edits register in the other order).
|
||||
let injector = self.get(plugin).is_ok_and(|p| {
|
||||
p.manifest().declarations("providers").iter().any(|d| {
|
||||
d.get("kinds")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|v| v.iter().any(|k| k.as_str() == Some("injections")))
|
||||
})
|
||||
});
|
||||
let projects = self.diagnostic_projects.lock().clone();
|
||||
for (state, sink) in projects {
|
||||
if let Some(state) = state.upgrade() {
|
||||
@@ -246,15 +231,7 @@ impl PluginHost {
|
||||
// A completed answer can still be waiting for another provider.
|
||||
*state.generations.entry(plugin.to_owned()).or_default() += 1;
|
||||
for doc in state.documents.values_mut() {
|
||||
if injector {
|
||||
for source in doc.sources.drain() {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{source}"),
|
||||
items: vec![],
|
||||
});
|
||||
}
|
||||
} else if doc.sources.remove(plugin) {
|
||||
if doc.sources.remove(plugin) {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: doc.snapshot.path.clone().into(),
|
||||
source: format!("plugin-provider:{plugin}"),
|
||||
|
||||
@@ -281,7 +281,7 @@ mod tests {
|
||||
#[test]
|
||||
fn validates_entire_rooted_batch_with_utf16_positions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let file = root.join("routes.rb");
|
||||
std::fs::write(&file, "😀x\n").unwrap();
|
||||
let good = endpoint(file.clone());
|
||||
|
||||
@@ -1,567 +0,0 @@
|
||||
//! Window-owned plugin operations. Instance reclamation does not end them.
|
||||
use super::api::{PluginApi, PromptEntry, Waiter};
|
||||
use super::{CommandAnswer, EventQueue, PluginEvent, PluginHost, WindowRequest};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_plugin::{ContentTab, ContentTabReply, HostError, WorkbenchEvent};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub(super) struct Operation {
|
||||
pub plugin: String,
|
||||
pub client: String,
|
||||
pub kind: OperationKind,
|
||||
pub root: Option<std::path::PathBuf>,
|
||||
pub started: bool,
|
||||
pub closed: bool,
|
||||
pub run: Option<ide_core::RunId>,
|
||||
pub terminal: Option<(std::sync::Weak<crate::Host>, ide_core::TerminalId)>,
|
||||
}
|
||||
|
||||
pub(super) enum OperationKind {
|
||||
Run { config: String, configuration: String, program: String },
|
||||
Terminal { key: String, command: ide_plugin::ProcessCommand },
|
||||
}
|
||||
|
||||
pub(super) fn label(value: &str, max: usize) -> Result<(), HostError> {
|
||||
if value.is_empty() || value.encode_utf16().count() > max || value.chars().any(char::is_control) {
|
||||
return Err(HostError::Invalid(format!("expected 1-{max} characters without controls")));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn body(value: &str) -> Result<(), HostError> {
|
||||
if value.encode_utf16().count() > 1 << 20 || value.contains('\0') {
|
||||
return Err(HostError::Invalid("content too large or contains NUL".into()));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_tab(tab: &ContentTab) -> Result<(), HostError> {
|
||||
label(&tab.key, 128)?;
|
||||
label(&tab.title, 256)?;
|
||||
body(&tab.content)?;
|
||||
if let Some(name) = &tab.file_name {
|
||||
label(name, 256)?;
|
||||
if name.contains(['/', '\\']) {
|
||||
return Err(HostError::Invalid("file-name cannot contain separators".into()));
|
||||
}
|
||||
}
|
||||
if let Some(language) = &tab.language {
|
||||
label(language, 64)?;
|
||||
}
|
||||
if tab.actions.len() > 16 || tab.actions.iter().filter(|a| a.save).count() > 1 {
|
||||
return Err(HostError::Invalid("at most 16 actions and one Save action".into()));
|
||||
}
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for action in &tab.actions {
|
||||
label(&action.id, 64)?;
|
||||
label(&action.label, 128)?;
|
||||
if !ids.insert(&action.id) {
|
||||
return Err(HostError::Invalid("duplicate action id".into()));
|
||||
}
|
||||
if let Some(icon) = &action.icon {
|
||||
label(icon, 128)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub(super) fn validate_reply(reply: &ContentTabReply) -> Result<(), HostError> {
|
||||
if let Some(content) = &reply.content {
|
||||
body(content)?;
|
||||
}
|
||||
if reply.status.as_ref().is_some_and(|s| s.len() > 512) || reply.error.as_ref().is_some_and(|s| s.len() > 1024) {
|
||||
return Err(HostError::Invalid("content status or error too long".into()));
|
||||
}
|
||||
if let Some(diff) = &reply.diff {
|
||||
body(&diff.left)?;
|
||||
body(&diff.right)?;
|
||||
for value in [&diff.left_label, &diff.right_label].into_iter().flatten() {
|
||||
label(value, 128)?;
|
||||
}
|
||||
if let Some(title) = &diff.title {
|
||||
label(title, 256)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
pub(super) fn new_operation(&self, client: &str, kind: OperationKind) -> Result<String, HostError> {
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if operations.values().filter(|o| o.plugin == self.pkg.id()).count() >= 32 {
|
||||
return Err(HostError::Invalid("at most 32 active operations".into()));
|
||||
}
|
||||
let id = format!("plugin-op-{}", self.shared.next_prompt.fetch_add(1, Ordering::Relaxed));
|
||||
operations.insert(
|
||||
id.clone(),
|
||||
Operation {
|
||||
plugin: self.pkg.id().into(),
|
||||
client: client.into(),
|
||||
kind,
|
||||
root: self.shared.project_of(client),
|
||||
started: false,
|
||||
closed: false,
|
||||
terminal: None,
|
||||
run: None,
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
pub(super) fn window_request(
|
||||
&self,
|
||||
client: &str,
|
||||
request: WindowRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<Option<String>, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let closed = self.shared.closed_clients.lock();
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if closed.contains(client) || blocked.contains(self.pkg.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(
|
||||
id,
|
||||
PromptEntry { plugin: self.pkg.id().into(), client: client.into(), answer: Waiter::Command(tx) },
|
||||
);
|
||||
}
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::WindowRequest { plugin_id: self.pkg.id().into(), request_id: id.to_string(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match rx.recv_timeout(Duration::from_millis(20)) {
|
||||
Ok(CommandAnswer::Value(value)) => return Ok(value),
|
||||
Ok(CommandAnswer::NotFound) => return Err(HostError::NotFound("window service unavailable".into())),
|
||||
Ok(CommandAnswer::Failed(error)) => return Err(HostError::Failed(error)),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(_) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// The launcher alone may finish its operation; duplicate and foreign reports are ignored.
|
||||
pub fn finish_operation(&self, client: &str, id: &str, status: Option<i32>, stopped: bool) -> bool {
|
||||
let operation = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if !operations.get(id).is_some_and(|op| op.client == client) {
|
||||
return false;
|
||||
}
|
||||
operations.remove(id).unwrap()
|
||||
};
|
||||
let event = match operation.kind {
|
||||
OperationKind::Run { config, .. } => {
|
||||
WorkbenchEvent::RunExited(ide_plugin::RunExited { id: id.into(), config, status, killed: stopped })
|
||||
}
|
||||
OperationKind::Terminal { key, .. } => WorkbenchEvent::TerminalExited(ide_plugin::TerminalExited {
|
||||
id: id.into(),
|
||||
key,
|
||||
status,
|
||||
closed: stopped,
|
||||
}),
|
||||
};
|
||||
self.post_owned_event(&operation.plugin, client, event);
|
||||
true
|
||||
}
|
||||
/// Content actions bypass subscriptions, just like completions of owned operations.
|
||||
pub fn content_tab_action(
|
||||
&self,
|
||||
client: &str,
|
||||
plugin: &str,
|
||||
action: ide_plugin::ContentTabAction,
|
||||
) -> IdeResult<()> {
|
||||
let tabs = self.shared.content_tabs.lock();
|
||||
let tab = tabs
|
||||
.get(&(plugin.into(), client.into(), action.key.clone()))
|
||||
.ok_or_else(|| IdeError::not_found("unknown content tab"))?;
|
||||
if !tab.actions.iter().any(|a| a.id == action.action) {
|
||||
return Err(IdeError::invalid("unknown content action"));
|
||||
}
|
||||
body(&action.content).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
label(&action.request_id, 128).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
drop(tabs);
|
||||
let mut pending = self.shared.content_requests.lock();
|
||||
pending.retain(|_, (_, created)| created.elapsed() < Duration::from_secs(15));
|
||||
if pending.keys().filter(|(p, w, _)| p == plugin && w == client).count() >= 32 {
|
||||
return Err(IdeError::invalid("too many pending content actions"));
|
||||
}
|
||||
pending.insert((plugin.into(), client.into(), action.request_id.clone()), (action.key.clone(), Instant::now()));
|
||||
drop(pending);
|
||||
self.post_owned_event(plugin, client, WorkbenchEvent::ContentTabAction(action));
|
||||
Ok(())
|
||||
}
|
||||
fn post_owned_event(&self, plugin: &str, client: &str, event: WorkbenchEvent) {
|
||||
if self.shared.client_closed(client) {
|
||||
return;
|
||||
}
|
||||
self.queues
|
||||
.lock()
|
||||
.entry((plugin.into(), Some(client.into())))
|
||||
.or_insert_with(|| EventQueue { since: Instant::now(), events: Vec::new(), files_source: None })
|
||||
.events
|
||||
.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic configurations opt in with marker files, just like static detectors.
|
||||
pub(super) fn configurations_declaration(
|
||||
pkg: &ide_extension::Package,
|
||||
value: toml::Value,
|
||||
) -> Result<ide_extension::UiDecl, String> {
|
||||
#[derive(serde::Deserialize, serde::Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
files: Vec<String>,
|
||||
}
|
||||
pkg.manifest.needs_api("contributes.runConfigurations", 4)?;
|
||||
let decl: Decl = ide_extension::decode("runConfigurations", value)?;
|
||||
if pkg.manifest.wasm.is_none() || decl.files.is_empty() || decl.files.len() > 16 {
|
||||
return Err("runConfigurations needs code and 1-16 marker files".into());
|
||||
}
|
||||
for file in &decl.files {
|
||||
ide_extension::check_relative(std::path::Path::new(file), "files")?;
|
||||
}
|
||||
Ok(ide_extension::UiDecl::new(
|
||||
serde_json::to_string(&decl.files).unwrap(),
|
||||
"Run configurations",
|
||||
serde_json::to_value(decl).unwrap(),
|
||||
))
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Refreshes configuration exports on the same listing path as static detectors.
|
||||
pub fn refresh_run_configurations(&self, root: &std::path::Path) {
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(root)) {
|
||||
return;
|
||||
}
|
||||
let owners: std::collections::BTreeSet<_> = self
|
||||
.ui
|
||||
.items("runConfigurations", None)
|
||||
.into_iter()
|
||||
.filter(|d| {
|
||||
d.decl["files"]
|
||||
.as_array()
|
||||
.is_some_and(|files| files.iter().filter_map(|f| f.as_str()).any(|f| root.join(f).is_file()))
|
||||
})
|
||||
.map(|d| d.plugin_id)
|
||||
.collect();
|
||||
self.shared.run_configurations.write().retain(|(p, r), _| r != root || owners.contains(p));
|
||||
for plugin in owners {
|
||||
let project = root.to_path_buf();
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(project.clone()), ..Default::default() };
|
||||
let result = self.code_call(&plugin, invocation, "run-configurations", |rt, ctx| {
|
||||
rt.run_configurations(
|
||||
ctx.workspace_root.unwrap().to_string_lossy().into_owned(),
|
||||
Duration::from_secs(2),
|
||||
)
|
||||
});
|
||||
let Ok(items) = result else { continue };
|
||||
if let Err(error) = validate_configurations(root, &items) {
|
||||
tracing::warn!(%plugin, %error, "invalid run configurations");
|
||||
continue;
|
||||
}
|
||||
self.shared.run_configurations.write().insert((plugin, project), items);
|
||||
}
|
||||
}
|
||||
/// Resolves only an owned plugin test configuration in the client's trusted project.
|
||||
fn test_configuration(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
) -> IdeResult<Option<(std::path::PathBuf, String, String)>> {
|
||||
let root = self.shared.project_of(client).ok_or_else(|| IdeError::not_found("no project"))?;
|
||||
if !self.shared.workspace().is_some_and(|w| w.trusted(&root)) {
|
||||
return Err(IdeError::invalid("project is not trusted"));
|
||||
}
|
||||
let Some(rest) = config.strip_prefix(ide_run::EXTERNAL_PREFIX) else { return Ok(None) };
|
||||
let Some((owner, id)) = rest.split_once(':') else { return Ok(None) };
|
||||
let configuration = self
|
||||
.shared
|
||||
.workspace()
|
||||
.ok_or_else(IdeError::cancelled)?
|
||||
.run_configuration(client, owner, id)
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !configuration.is_test || configuration.extras.params.get("framework").map(String::as_str) != Some("plugin")
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some((root, owner.to_owned(), id.to_owned())))
|
||||
}
|
||||
|
||||
/// Derives replacement argv under the parser's soft/hard call budgets.
|
||||
pub fn select_tests(&self, client: &str, config: &str, tests: Vec<Vec<String>>) -> IdeResult<Option<Vec<String>>> {
|
||||
if tests.is_empty()
|
||||
|| tests.len() > 10000
|
||||
|| tests.iter().any(|p| p.is_empty() || p.len() > 128)
|
||||
|| serde_json::to_vec(&tests).is_ok_and(|b| b.len() > 4 << 20)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root), ..Default::default() };
|
||||
let hard = self.limits.edit_hard;
|
||||
let started = Instant::now();
|
||||
let result = actor.call(Some(self.limits.edit_soft), move |rt| rt.select_tests(id, tests, invocation, hard));
|
||||
let args = self.finish_provider_call(&plugin, &actor, started, result).flatten();
|
||||
Ok(args.filter(|args| args.len() <= 10000 && args.iter().map(String::len).sum::<usize>() <= 4 << 20))
|
||||
}
|
||||
|
||||
/// Parses a bounded batch under the editor-provider soft/hard budgets.
|
||||
pub fn parse_test_results(
|
||||
&self,
|
||||
client: &str,
|
||||
config: &str,
|
||||
chunk: String,
|
||||
) -> IdeResult<Option<Vec<ide_plugin::PluginTestEvent>>> {
|
||||
if chunk.len() > 64 << 10 {
|
||||
return Err(IdeError::invalid("test output batch too large"));
|
||||
}
|
||||
let Some((root, owner, id)) = self.test_configuration(client, config)? else { return Ok(None) };
|
||||
let plugin = self.get(&owner)?;
|
||||
let (actor, activated) = self.ensure_active(&plugin, None)?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let invocation = ide_plugin::Invocation { workspace_root: Some(root.clone()), ..Default::default() };
|
||||
let (id, hard) = (id.to_owned(), self.limits.edit_hard);
|
||||
let started = Instant::now();
|
||||
let result =
|
||||
actor.call(Some(self.limits.edit_soft), move |rt| rt.parse_test_results(id, chunk, invocation, hard));
|
||||
let mut events = self.finish_provider_call(&plugin, &actor, started, result);
|
||||
if let Some(events) = &mut events {
|
||||
if serde_json::to_vec(&*events).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Ok(None);
|
||||
}
|
||||
for event in events {
|
||||
let location = match event {
|
||||
ide_plugin::PluginTestEvent::SuiteStarted(v) | ide_plugin::PluginTestEvent::TestStarted(v) => {
|
||||
v.location.as_mut()
|
||||
}
|
||||
ide_plugin::PluginTestEvent::Failed(v) => v.location.as_mut(),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(location) = location {
|
||||
let Ok(path) = ide_plugin::resolve_new_in_root(&root, &location.path.to_string_lossy()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if location.range.end < location.range.start {
|
||||
return Ok(None);
|
||||
}
|
||||
location.path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_configurations(
|
||||
root: &std::path::Path,
|
||||
items: &[ide_plugin::PluginRunConfiguration],
|
||||
) -> Result<(), HostError> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
if items.len() > 1000 || serde_json::to_vec(items).is_ok_and(|b| b.len() > 4 << 20) {
|
||||
return Err(HostError::Invalid("too many run configurations".into()));
|
||||
}
|
||||
for c in items {
|
||||
ide_extension::validate_item_id(&c.id, "run configuration id").map_err(HostError::Invalid)?;
|
||||
label(&c.name, 128)?;
|
||||
if !ids.insert(&c.id)
|
||||
|| c.program.is_empty()
|
||||
|| c.program.len() > 256
|
||||
|| c.program.contains(char::is_whitespace)
|
||||
|| c.args.len() > 256
|
||||
|| c.args.iter().any(|a| a.len() > 4096 || a.contains('\0'))
|
||||
{
|
||||
return Err(HostError::Invalid("invalid run configuration".into()));
|
||||
}
|
||||
if let Some(cwd) = &c.cwd {
|
||||
ide_plugin::resolve_new_in_root(root, cwd).map_err(HostError::Invalid)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Resolves and rechecks the pin immediately before starting a keyed terminal's PTY.
|
||||
pub fn spawn_terminal_session(
|
||||
self: &std::sync::Arc<Self>,
|
||||
host: &std::sync::Arc<crate::Host>,
|
||||
client: &str,
|
||||
operation: &str,
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
output: crate::ops::Emit<String>,
|
||||
) -> IdeResult<ide_terminal::TerminalInfo> {
|
||||
let (plugin, command) = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let op = operations
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client)
|
||||
.ok_or_else(|| IdeError::not_found("unknown terminal operation"))?;
|
||||
if op.started || op.closed || op.root != self.shared.project_of(client) {
|
||||
return Err(IdeError::invalid("terminal operation already started or project changed"));
|
||||
}
|
||||
let OperationKind::Terminal { command, .. } = &op.kind else {
|
||||
return Err(IdeError::invalid("not a terminal operation"));
|
||||
};
|
||||
op.started = true;
|
||||
(op.plugin.clone(), command.clone())
|
||||
};
|
||||
let options = super::web::WebPtyCommand {
|
||||
program: command.program,
|
||||
args: command.args,
|
||||
env: command.env.into_iter().collect(),
|
||||
cols,
|
||||
rows,
|
||||
};
|
||||
let weak = Arc::downgrade(self);
|
||||
let (id, window) = (operation.to_owned(), client.to_owned());
|
||||
let completion = Arc::new(move |status| {
|
||||
if let Some(plugins) = weak.upgrade() {
|
||||
let closed = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&window, &id, status, closed);
|
||||
}
|
||||
});
|
||||
let result = self.spawn_plugin_pty(host, &plugin, client, output, Some(completion), || {
|
||||
self.pty_options(&plugin, client, options)
|
||||
});
|
||||
if let Ok(info) = &result {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
if let Some(op) = operations.get_mut(operation) {
|
||||
op.terminal = Some((Arc::downgrade(host), info.id));
|
||||
if op.closed {
|
||||
let _ = host.terminals.kill(info.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if result.is_err()
|
||||
&& let Some(op) = self.shared.operations.lock().get_mut(operation)
|
||||
{
|
||||
op.started = false;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Closing a tab marks the operation before killing its PTY. The real exit emits the event.
|
||||
pub fn close_terminal_session(&self, client: &str, id: &str) -> IdeResult<()> {
|
||||
let terminal = {
|
||||
let mut operations = self.shared.operations.lock();
|
||||
let Some(op) = operations
|
||||
.get_mut(id)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Terminal { .. }))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
op.closed = true;
|
||||
op.terminal.clone()
|
||||
};
|
||||
if let Some((host, id)) = terminal
|
||||
&& let Some(host) = host.upgrade()
|
||||
{
|
||||
host.terminals.kill(id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Rechecks the owned configuration's program before Debug and every IDE rerun.
|
||||
pub fn prepare_debug_run(&self, client: &str, mut config: ide_run::RunConfig) -> IdeResult<ide_run::RunConfig> {
|
||||
let Some((plugin, id)) = config.id.strip_prefix(ide_run::EXTERNAL_PREFIX).and_then(|s| s.split_once(':'))
|
||||
else {
|
||||
return Err(IdeError::invalid("not a plugin configuration"));
|
||||
};
|
||||
let workspace = self.shared.workspace().ok_or_else(IdeError::cancelled)?;
|
||||
let owned = workspace.run_configuration(client, plugin, id).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if owned.debug_adapter.is_none() {
|
||||
return Err(IdeError::invalid("this configuration has no debugger"));
|
||||
}
|
||||
if config.extras.target.is_none() {
|
||||
config.extras.target = owned.extras.target.clone();
|
||||
}
|
||||
if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
return Err(IdeError::invalid("debugging this target is not supported"));
|
||||
}
|
||||
config.command = self
|
||||
.program_for_project(plugin, &owned.command, self.shared.project_of(client).as_deref())?
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
config.debug_adapter = owned.debug_adapter;
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal launch metadata; it is ignored after the original operation finishes.
|
||||
pub(super) const RUN_OPERATION: &str = "plugin.operation";
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_operation_sink(
|
||||
self: &Arc<Self>,
|
||||
client: &str,
|
||||
config: &mut ide_run::RunConfig,
|
||||
previous: Arc<dyn ide_core::EventSink>,
|
||||
) -> IdeResult<Arc<dyn ide_core::EventSink>> {
|
||||
let Some(id) = config.extras.params.get(RUN_OPERATION).cloned() else { return Ok(previous) };
|
||||
let origin = self.shared.operations.lock().get(&id).and_then(|op| {
|
||||
if op.client != client {
|
||||
return None;
|
||||
}
|
||||
let OperationKind::Run { configuration, program, .. } = &op.kind else { return None };
|
||||
(configuration == &config.id).then(|| (op.plugin.clone(), op.root.clone(), program.clone()))
|
||||
});
|
||||
let Some((plugin, root, program)) = origin else { return Ok(previous) };
|
||||
let binary = self.program_for_project(&plugin, &program, root.as_deref())?;
|
||||
config.command = if config.extras.target.as_ref().is_some_and(|t| !t.is_empty()) {
|
||||
program
|
||||
} else {
|
||||
binary.to_string_lossy().into_owned()
|
||||
};
|
||||
config.detected = false;
|
||||
let (weak, client) = (Arc::downgrade(self), client.to_owned());
|
||||
Ok(Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if let ide_core::IdeEvent::RunExited { code, .. } = &event
|
||||
&& let Some(plugins) = weak.upgrade()
|
||||
{
|
||||
let stopped = plugins.shared.operations.lock().get(&id).is_some_and(|op| op.closed);
|
||||
plugins.finish_operation(&client, &id, *code, stopped);
|
||||
}
|
||||
previous.emit(event);
|
||||
}))
|
||||
}
|
||||
pub(crate) fn run_operation_started(&self, client: &str, operation: &str, run: ide_core::RunId) {
|
||||
if let Some(op) = self
|
||||
.shared
|
||||
.operations
|
||||
.lock()
|
||||
.get_mut(operation)
|
||||
.filter(|op| op.client == client && matches!(op.kind, OperationKind::Run { .. }))
|
||||
{
|
||||
op.run = Some(run);
|
||||
}
|
||||
}
|
||||
pub(crate) fn run_operation_stopped(&self, run: ide_core::RunId) {
|
||||
for op in self.shared.operations.lock().values_mut().filter(|op| op.run == Some(run)) {
|
||||
op.closed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,6 @@ pub async fn plugin_navigation_index(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation {
|
||||
command_chain: Vec::new(),
|
||||
client: Some(client.clone()),
|
||||
workspace_root: Some(project.root.clone()),
|
||||
editor: None,
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::Injection};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Allowed output ranges for one virtual document; the host document is unrestricted.
|
||||
pub(super) struct Bounds(Option<Vec<Range>>);
|
||||
impl Bounds {
|
||||
pub fn unrestricted() -> Self {
|
||||
Self(None)
|
||||
}
|
||||
pub fn contains(&self, range: Range) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start <= range.start && range.end <= r.end))
|
||||
}
|
||||
pub fn lines(&self, start: u32, end: u32) -> bool {
|
||||
self.0.as_ref().is_none_or(|rs| rs.iter().any(|r| r.start.line <= start && end <= r.end.line))
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn provide_injections(&self, doc: &ProviderDocument) -> Vec<Injection> {
|
||||
if !self.providers.any(ProviderKind::Injections) {
|
||||
return vec![];
|
||||
}
|
||||
if doc.text.len() > super::navigation::MAX_DOCUMENT_BYTES {
|
||||
return Vec::new();
|
||||
}
|
||||
let text = ide_text::Document::new(&doc.text);
|
||||
let d = Arc::new(doc.clone());
|
||||
let mut items: Vec<_> = self
|
||||
.document_calls(ProviderKind::Injections, doc, move |rt, t| rt.provide_injections(&d, t))
|
||||
.into_iter()
|
||||
.flat_map(|(_, v)| v)
|
||||
.filter(|i| {
|
||||
!i.range.is_empty()
|
||||
&& super::language_features::valid_range_in(&text, i.range)
|
||||
&& i.language != doc.language
|
||||
&& !i.language.is_empty()
|
||||
&& i.language.len() <= 64
|
||||
&& i.language.chars().all(|c| c.is_ascii_alphanumeric() || "-_.+#".contains(c))
|
||||
})
|
||||
.collect();
|
||||
items.sort_by_key(|i| i.range);
|
||||
let mut end = Pos::default();
|
||||
items.retain(|i| {
|
||||
if i.range.start < end {
|
||||
return false;
|
||||
}
|
||||
end = i.range.end;
|
||||
true
|
||||
});
|
||||
items.truncate(1000);
|
||||
items
|
||||
}
|
||||
|
||||
pub(super) fn feature_documents(&self, doc: &ProviderDocument, at: Option<Pos>) -> Vec<(ProviderDocument, Bounds)> {
|
||||
let injections = self.provide_injections(doc);
|
||||
let mut languages = Vec::new();
|
||||
for i in &injections {
|
||||
if at.is_some_and(|p| p < i.range.start || p >= i.range.end) || languages.contains(&i.language) {
|
||||
continue;
|
||||
}
|
||||
languages.push(i.language.clone());
|
||||
if languages.len() == 32 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut docs = Vec::new();
|
||||
for language in languages {
|
||||
let ranges = injections.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let text = virtual_text(doc, &injections, &language);
|
||||
docs.push((ProviderDocument { language, text, ..doc.clone() }, Bounds(Some(ranges))));
|
||||
}
|
||||
docs.push((doc.clone(), Bounds(None)));
|
||||
docs
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], language: &str) -> String {
|
||||
let ranges: Vec<_> = ranges.iter().filter(|i| i.language == language).map(|i| i.range).collect();
|
||||
let mut index = 0;
|
||||
let mut at = Pos::default();
|
||||
let mut text = String::with_capacity(doc.text.len());
|
||||
for c in doc.text.chars() {
|
||||
while index < ranges.len() && ranges[index].end <= at {
|
||||
index += 1;
|
||||
}
|
||||
let inside = ranges.get(index).is_some_and(|r| r.start <= at && at < r.end);
|
||||
if inside || c == '\n' || c == '\r' {
|
||||
text.push(c);
|
||||
} else {
|
||||
for _ in 0..c.len_utf16() {
|
||||
text.push(' ');
|
||||
}
|
||||
}
|
||||
if c == '\n' {
|
||||
at.line += 1;
|
||||
at.col = 0;
|
||||
} else {
|
||||
at.col += c.len_utf16() as u32;
|
||||
}
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn virtual_documents_preserve_utf16_columns_and_newlines() {
|
||||
let d = ProviderDocument {
|
||||
path: "/p/a".into(),
|
||||
language: "host".into(),
|
||||
version: 7,
|
||||
text: "😀 sql\n outside\n sql".into(),
|
||||
};
|
||||
let ranges = vec![
|
||||
Injection { range: Range::new(Pos::new(0, 3), Pos::new(0, 6)), language: "sql".into() },
|
||||
Injection { range: Range::new(Pos::new(2, 1), Pos::new(2, 4)), language: "sql".into() },
|
||||
];
|
||||
let text = virtual_text(&d, &ranges, "sql");
|
||||
assert_eq!(text, " sql\n \n sql");
|
||||
assert_eq!(text.encode_utf16().count(), d.text.encode_utf16().count());
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
//! Plugin-owned local byte streams, closed on resource drop and plugin shutdown.
|
||||
use super::api::net_runtime;
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::HostError;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
|
||||
|
||||
trait Stream: AsyncRead + AsyncWrite + Unpin + Send {}
|
||||
impl<T: AsyncRead + AsyncWrite + Unpin + Send> Stream for T {}
|
||||
struct Connection {
|
||||
stream: tokio::sync::Mutex<Box<dyn Stream>>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
impl Drop for Connection {
|
||||
fn drop(&mut self) {
|
||||
self.closed.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct Connections {
|
||||
next: AtomicU64,
|
||||
entries: Mutex<HashMap<(String, u64), Arc<Connection>>>,
|
||||
}
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
impl Connections {
|
||||
pub fn connect(
|
||||
&self,
|
||||
plugin: &str,
|
||||
name: &str,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<u64, HostError> {
|
||||
let check = |entries: &HashMap<(String, u64), Arc<Connection>>| {
|
||||
if entries.keys().filter(|(p, _)| p == plugin).count() >= 8 {
|
||||
Err(HostError::Invalid("at most eight IPC connections per plugin".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
check(&self.entries.lock())?;
|
||||
let stream: Box<dyn Stream> = net_runtime().block_on(async {
|
||||
let connect = async {
|
||||
#[cfg(unix)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::UnixStream::connect(name).await.map_err(failed)?)) }
|
||||
#[cfg(windows)]
|
||||
{ Ok::<Box<dyn Stream>, HostError>(Box::new(tokio::net::windows::named_pipe::ClientOptions::new().open(name).map_err(failed)?)) }
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{ let _ = name; Err(HostError::Failed("local IPC unavailable".into())) }
|
||||
};
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, connect) => r.map_err(|_| HostError::Failed("IPC connection timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})?;
|
||||
let mut entries = self.entries.lock();
|
||||
check(&entries)?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let id = self.next.fetch_add(1, Ordering::Relaxed);
|
||||
entries.insert(
|
||||
(plugin.into(), id),
|
||||
Arc::new(Connection { stream: tokio::sync::Mutex::new(stream), closed: CancellationToken::new() }),
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
fn get(&self, plugin: &str, id: u64) -> Result<Arc<Connection>, HostError> {
|
||||
self.entries
|
||||
.lock()
|
||||
.get(&(plugin.into(), id))
|
||||
.cloned()
|
||||
.ok_or_else(|| HostError::NotFound("IPC connection".into()))
|
||||
}
|
||||
pub fn write(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
bytes: Vec<u8>,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<(), HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
tokio::select! {
|
||||
r = tokio::time::timeout(timeout, async { entry.stream.lock().await.write_all(&bytes).await.map_err(failed) }) => r.map_err(|_| HostError::Failed("IPC write timed out".into()))?,
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn read(
|
||||
&self,
|
||||
plugin: &str,
|
||||
id: u64,
|
||||
max: usize,
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Vec<u8>, HostError> {
|
||||
let entry = self.get(plugin, id)?;
|
||||
net_runtime().block_on(async {
|
||||
let read = async {
|
||||
let mut bytes = vec![0; max.min(1 << 20)];
|
||||
let count = entry.stream.lock().await.read(&mut bytes).await.map_err(failed)?;
|
||||
bytes.truncate(count);
|
||||
Ok(bytes)
|
||||
};
|
||||
tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => Err(HostError::Cancelled),
|
||||
() = entry.closed.cancelled() => Err(HostError::Cancelled),
|
||||
r = read => r,
|
||||
() = tokio::time::sleep(timeout) => Ok(Vec::new()),
|
||||
}
|
||||
})
|
||||
}
|
||||
pub fn close(&self, plugin: &str, id: u64) {
|
||||
if let Some(entry) = self.entries.lock().remove(&(plugin.into(), id)) {
|
||||
entry.closed.cancel();
|
||||
}
|
||||
}
|
||||
pub fn clear(&self, plugin: &str) {
|
||||
self.entries.lock().retain(|(p, _), entry| {
|
||||
if p != plugin {
|
||||
return true;
|
||||
}
|
||||
entry.closed.cancel();
|
||||
false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
use std::os::unix::net::UnixListener;
|
||||
#[test]
|
||||
fn echo_limit_and_shutdown() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("echo.sock");
|
||||
let listener = UnixListener::bind(&path).unwrap();
|
||||
let worker = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().unwrap();
|
||||
let mut bytes = [0; 4];
|
||||
stream.read_exact(&mut bytes).unwrap();
|
||||
stream.write_all(&bytes).unwrap();
|
||||
// Accept the remaining seven connections before shutdown.
|
||||
for _ in 1..8 {
|
||||
listener.accept().unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Connections::default();
|
||||
let token = CancellationToken::new();
|
||||
let timeout = Duration::from_secs(2);
|
||||
let id = pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
pool.write("a", id, b"echo".to_vec(), &token, timeout).unwrap();
|
||||
assert_eq!(pool.read("a", id, 4, timeout, &token).unwrap(), b"echo");
|
||||
for _ in 1..8 {
|
||||
pool.connect("a", path.to_str().unwrap(), &token, timeout).unwrap();
|
||||
}
|
||||
assert!(matches!(pool.connect("a", path.to_str().unwrap(), &token, timeout), Err(HostError::Invalid(_))));
|
||||
assert!(pool.read("b", id, 4, timeout, &token).is_err());
|
||||
pool.clear("a");
|
||||
assert!(pool.read("a", id, 4, timeout, &token).is_err());
|
||||
worker.join().unwrap();
|
||||
}
|
||||
}
|
||||
@@ -1,462 +0,0 @@
|
||||
//! API 1.4 language features. Results are bounded and checked against the snapshot.
|
||||
use super::{PluginHost, ProviderKind};
|
||||
use ide_core::{IdeResult, Pos, Range};
|
||||
use ide_plugin::{ProviderDocument, language_features::*};
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn valid_range(doc: &ProviderDocument, range: Range) -> bool {
|
||||
valid_range_in(&ide_text::Document::new(&doc.text), range)
|
||||
}
|
||||
pub(super) fn valid_range_in(text: &ide_text::Document, range: Range) -> bool {
|
||||
if range.start > range.end {
|
||||
return false;
|
||||
}
|
||||
[range.start, range.end].iter().all(|p| text.offset_to_pos(text.pos_to_offset(*p)) == *p)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub fn language_format(
|
||||
&self,
|
||||
id: &str,
|
||||
doc: &ProviderDocument,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<ide_core::TextEdit>> {
|
||||
let candidates = if let Some(r) = range {
|
||||
self.feature_documents(doc, Some(r.start))
|
||||
} else {
|
||||
vec![(doc.clone(), super::injections::Bounds::unrestricted())]
|
||||
};
|
||||
let (target, bounds) = candidates
|
||||
.into_iter()
|
||||
.find(|(d, b)| {
|
||||
range.is_none_or(|r| b.contains(r))
|
||||
&& self
|
||||
.provider_plugins(ProviderKind::Formatting, &d.language)
|
||||
.iter()
|
||||
.any(|p| p.id() == id && p.dev_root.as_ref().is_none_or(|r| d.path.starts_with(r)))
|
||||
})
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("The selected plugin formatter is unavailable"))?;
|
||||
let text = ide_text::Document::new(&target.text);
|
||||
let d = Arc::new(target);
|
||||
let edits = self.code_call(id, ide_plugin::Invocation::default(), "formatting", move |rt, _| {
|
||||
rt.language_format(&d, range, options.clone(), std::time::Duration::from_secs(5))
|
||||
})?;
|
||||
if edits.iter().any(|e| !valid_range_in(&text, e.range) || !bounds.contains(e.range)) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned an invalid range"));
|
||||
}
|
||||
let mut ranges: Vec<_> = edits.iter().map(|e| e.range).collect();
|
||||
ranges.sort();
|
||||
if ranges.windows(2).any(|p| p[0].end > p[1].start) {
|
||||
return Err(ide_core::IdeError::invalid("Plugin formatter returned overlapping edits"));
|
||||
}
|
||||
Ok(edits)
|
||||
}
|
||||
pub fn language_signature_help(&self, doc: &ProviderDocument, at: Pos) -> Option<SignatureHelp> {
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SignatureHelp, &snapshot, move |rt, t| {
|
||||
rt.language_signature_help(&d, at, t)
|
||||
});
|
||||
for (_, v) in results {
|
||||
if let Some(v) = v.filter(|h| !h.signatures.is_empty()) {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
pub fn language_document_highlights(&self, doc: &ProviderDocument, at: Pos) -> Vec<Highlight> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentHighlights, &snapshot, move |rt, t| {
|
||||
rt.language_document_highlights(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_document_links(&self, doc: &ProviderDocument) -> Vec<DocumentLink> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::DocumentLinks, &snapshot, move |rt, t| rt.language_document_links(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_code_lenses(&self, doc: &ProviderDocument) -> Vec<(String, CodeLens)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::CodeLenses, &snapshot, move |rt, t| rt.language_code_lenses(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range) && bounds.contains(i.range) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_selection_ranges(&self, doc: &ProviderDocument, at: Pos) -> Vec<Range> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::SelectionRanges, &snapshot, move |rt, t| {
|
||||
rt.language_selection_ranges(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if valid_range_in(&text, i) && i.start <= at && at <= i.end && bounds.contains(i) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.start.cmp(&a.start).then(a.end.cmp(&b.end)));
|
||||
out.dedup();
|
||||
let mut end = None;
|
||||
out.retain(|r| {
|
||||
let ok = end.is_none_or(|prev: Range| r.start <= prev.start && prev.end <= r.end);
|
||||
if ok {
|
||||
end = Some(*r);
|
||||
}
|
||||
ok
|
||||
});
|
||||
out
|
||||
}
|
||||
pub fn language_folding(&self, doc: &ProviderDocument) -> Vec<FoldRange> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results =
|
||||
self.document_calls(ProviderKind::Folding, &snapshot, move |rt, t| rt.language_folding(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
if i.start_line < i.end_line && i.end_line < lines && bounds.lines(i.start_line, i.end_line) {
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_colors(&self, doc: &ProviderDocument) -> Vec<(String, ColorInfo)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Colors, &snapshot, move |rt, t| rt.language_colors(&d, t));
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
if valid_range_in(&text, i.range)
|
||||
&& bounds.contains(i.range)
|
||||
&& [i.red, i.green, i.blue, i.alpha].iter().all(|c| c.is_finite() && (0.0..=1.0).contains(c))
|
||||
{
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_color_presentations(&self, id: &str, doc: &ProviderDocument, color: ColorInfo) -> Vec<String> {
|
||||
for (d, bounds) in self.feature_documents(doc, Some(color.range.start)) {
|
||||
if !bounds.contains(color.range)
|
||||
|| !self.provider_plugins(ProviderKind::Colors, &d.language).iter().any(|p| p.id() == id)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let color = color.clone();
|
||||
let result = self.document_calls_for(ProviderKind::Colors, &snapshot, Some(id), move |rt, t| {
|
||||
rt.language_color_presentations(&d, color.clone(), t)
|
||||
});
|
||||
if let Some((_, items)) = result.into_iter().find(|(p, _)| p.id() == id) {
|
||||
return items.into_iter().take(100).collect();
|
||||
}
|
||||
}
|
||||
Vec::new()
|
||||
}
|
||||
pub fn language_document_symbols(&self, doc: &ProviderDocument) -> Vec<DocumentSymbol> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let snapshot = d.clone();
|
||||
let text = ide_text::Document::new(&snapshot.text);
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::DocumentSymbols, &snapshot, move |rt, t| {
|
||||
rt.language_document_symbols(&d, t)
|
||||
});
|
||||
for (_, items) in results {
|
||||
let mut remap = std::collections::HashMap::new();
|
||||
for (index, mut i) in items.into_iter().enumerate() {
|
||||
if !valid_range_in(&text, i.range)
|
||||
|| !valid_range_in(&text, i.selection_range)
|
||||
|| i.selection_range.start < i.range.start
|
||||
|| i.selection_range.end > i.range.end
|
||||
|| !bounds.contains(i.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
i.parent = i.parent.and_then(|p| remap.get(&(p as usize)).copied());
|
||||
remap.insert(index, out.len() as u32);
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_implementation(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::Implementation, &snapshot, move |rt, t| {
|
||||
rt.language_implementation(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_type_definition(&self, doc: &ProviderDocument, at: Pos) -> Vec<ide_plugin::Location> {
|
||||
let mut out = Vec::new();
|
||||
for (d, _bounds) in self.feature_documents(doc, Some(at)) {
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self.document_calls(ProviderKind::TypeDefinition, &snapshot, move |rt, t| {
|
||||
rt.language_type_definition(&d, at, t)
|
||||
});
|
||||
for (p, items) in results {
|
||||
for i in items {
|
||||
let _ = &p;
|
||||
out.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
pub fn language_gutter_actions(&self, doc: &ProviderDocument) -> Vec<(String, GutterAction)> {
|
||||
let mut out = Vec::new();
|
||||
for (d, bounds) in self.feature_documents(doc, None) {
|
||||
let lines = d.text.split('\n').count() as u32;
|
||||
let snapshot = d.clone();
|
||||
let d = Arc::new(d);
|
||||
let results = self
|
||||
.document_calls(ProviderKind::GutterActions, &snapshot, move |rt, t| rt.language_gutter_actions(&d, t));
|
||||
for (p, items) in results {
|
||||
let commands: std::collections::HashSet<_> =
|
||||
self.ui.items(super::COMMANDS, Some(p.id())).into_iter().map(|c| c.key).collect();
|
||||
for i in items {
|
||||
if i.line < lines && bounds.lines(i.line, i.line) && commands.contains(&i.command) {
|
||||
out.push((p.id().to_owned(), i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn run_language_command(
|
||||
&self,
|
||||
plugin: &str,
|
||||
command: &str,
|
||||
argument: Option<&str>,
|
||||
invocation: ide_plugin::Invocation,
|
||||
) -> IdeResult<()> {
|
||||
if !self.ui.items(super::COMMANDS, Some(plugin)).iter().any(|c| c.key == command) {
|
||||
return Err(ide_core::IdeError::not_found("The plugin did not declare this command"));
|
||||
}
|
||||
self.run_command(plugin, command, argument, invocation).map(|_| ())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn signature_help(h: SignatureHelp) -> ide_lsp::types::SignatureHelp {
|
||||
let signatures: Vec<_> = h
|
||||
.signatures
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|s| {
|
||||
let len = s.label.encode_utf16().count() as u32;
|
||||
let parameters = s
|
||||
.parameters
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|p| match p.label {
|
||||
ParameterLabel::Offsets((a, b)) => (a.min(len), b.max(a).min(len)),
|
||||
ParameterLabel::Text(t) => s
|
||||
.label
|
||||
.find(&t)
|
||||
.map(|a| {
|
||||
let start = s.label[..a].encode_utf16().count() as u32;
|
||||
(start, start + t.encode_utf16().count() as u32)
|
||||
})
|
||||
.unwrap_or((0, 0)),
|
||||
})
|
||||
.collect();
|
||||
ide_lsp::types::Signature {
|
||||
label: s.label,
|
||||
documentation: s.documentation.map(|value| ide_lsp::types::Markup { kind: "markdown".into(), value }),
|
||||
parameters,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let active_signature = h.active_signature.min(signatures.len().saturating_sub(1) as u32);
|
||||
let active_parameter =
|
||||
h.active_parameter
|
||||
.min(signatures.get(active_signature as usize).map(|s| s.parameters.len().saturating_sub(1)).unwrap_or(0)
|
||||
as u32);
|
||||
ide_lsp::types::SignatureHelp { signatures, active_signature, active_parameter }
|
||||
}
|
||||
|
||||
pub(crate) fn outline(items: Vec<DocumentSymbol>) -> Vec<ide_core::OutlineItem> {
|
||||
// A parent must precede its child, ruling out cycles and unbounded recursion.
|
||||
let mut depths = vec![0usize; items.len()];
|
||||
let mut parents = vec![None; items.len()];
|
||||
for (idx, item) in items.iter().enumerate() {
|
||||
if let Some(parent) = item.parent.map(|p| p as usize).filter(|p| {
|
||||
*p < idx
|
||||
&& depths[*p] < 63
|
||||
&& items[*p].range.start <= item.range.start
|
||||
&& item.range.end <= items[*p].range.end
|
||||
}) {
|
||||
parents[idx] = Some(parent);
|
||||
depths[idx] = depths[parent] + 1;
|
||||
}
|
||||
}
|
||||
let mut nodes: Vec<_> = items
|
||||
.into_iter()
|
||||
.map(|i| {
|
||||
Some(ide_core::OutlineItem {
|
||||
name: i.name,
|
||||
detail: Some(i.detail).filter(|s| !s.is_empty()),
|
||||
kind: serde_json::from_value(serde_json::Value::String(symbol_kind_name(&i.kind)))
|
||||
.unwrap_or(ide_core::SymbolKind::Unknown),
|
||||
range: i.range,
|
||||
selection_range: i.selection_range,
|
||||
children: vec![],
|
||||
visibility: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let mut roots = Vec::new();
|
||||
for idx in (0..nodes.len()).rev() {
|
||||
let mut node = nodes[idx].take().expect("once");
|
||||
node.children.reverse();
|
||||
if let Some(parent) = parents[idx] {
|
||||
nodes[parent].as_mut().expect("parent precedes child").children.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
}
|
||||
roots.reverse();
|
||||
roots
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FormatterChoice {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub languages: Vec<String>,
|
||||
}
|
||||
impl PluginHost {
|
||||
pub fn formatter_choices(&self) -> Vec<FormatterChoice> {
|
||||
let mut out = std::collections::BTreeMap::<String, FormatterChoice>::new();
|
||||
for (id, languages) in self.providers.formatter_declarations() {
|
||||
let Ok(p) = self.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
if !matches!(p.meta.lock().status, super::PluginStatus::Active | super::PluginStatus::Inactive) {
|
||||
continue;
|
||||
}
|
||||
out.entry(id.clone())
|
||||
.or_insert_with(|| FormatterChoice { id, name: p.pkg.manifest.name.clone(), languages: vec![] })
|
||||
.languages
|
||||
.extend(languages);
|
||||
}
|
||||
out.into_values()
|
||||
.map(|mut c| {
|
||||
c.languages.sort();
|
||||
c.languages.dedup();
|
||||
c
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn symbol_kind_name(kind: &str) -> String {
|
||||
let mut parts = kind.split('-');
|
||||
let mut out = parts.next().unwrap_or_default().to_owned();
|
||||
for part in parts {
|
||||
let mut chars = part.chars();
|
||||
if let Some(c) = chars.next() {
|
||||
out.extend(c.to_uppercase());
|
||||
out.extend(chars);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn outline_uses_parent_order_containment_and_string_kinds() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(0, 10));
|
||||
let item = |name: &str, parent| DocumentSymbol {
|
||||
name: name.into(),
|
||||
detail: String::new(),
|
||||
kind: "enum-member".into(),
|
||||
range,
|
||||
selection_range: range,
|
||||
parent,
|
||||
};
|
||||
let out =
|
||||
outline(vec![item("root", None), item("child", Some(0)), item("future", Some(3)), item("other", Some(2))]);
|
||||
assert_eq!(out[0].children[0].kind, ide_core::SymbolKind::EnumMember);
|
||||
assert_eq!(out[1].name, "future");
|
||||
assert_eq!(out[1].children[0].name, "other");
|
||||
}
|
||||
}
|
||||
@@ -1,176 +0,0 @@
|
||||
//! Validated MCP declarations and dispatch through the ordinary plugin actor.
|
||||
use super::{PluginHost, PluginStatus};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::{Package, UiDecl, decode};
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
pub(super) const POINT: &str = "mcpTools";
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct Declaration {
|
||||
name: String,
|
||||
description: String,
|
||||
input_schema: std::path::PathBuf,
|
||||
read_only: bool,
|
||||
destructive: bool,
|
||||
}
|
||||
fn tool_name(plugin: &str, name: &str) -> String {
|
||||
let plugin: String = plugin
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' { c } else { '_' })
|
||||
.collect();
|
||||
format!("{plugin}__{name}")
|
||||
}
|
||||
pub(super) fn declaration(pkg: &Package, value: toml::Value) -> Result<UiDecl, String> {
|
||||
pkg.manifest.needs_api("contributes.mcpTools", 4)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err("mcpTools requires wasm".into());
|
||||
}
|
||||
let d: Declaration = decode(POINT, value)?;
|
||||
if d.name.is_empty()
|
||||
|| d.name.len() > 40
|
||||
|| !d.name.bytes().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_')
|
||||
|| tool_name(pkg.id(), &d.name).len() > 64
|
||||
|| d.description.trim().is_empty()
|
||||
|| d.description.len() > 4096
|
||||
{
|
||||
return Err(
|
||||
"mcpTools: name must match [a-z0-9_]{1,40}, namespaced name at most 64 bytes, description 1–4096 bytes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let text = pkg.read_text(&d.input_schema)?;
|
||||
if text.len() > 256 << 10 {
|
||||
return Err("mcpTools: schema exceeds 256 KiB".into());
|
||||
}
|
||||
let schema: serde_json::Value = serde_json::from_str(&text).map_err(|e| format!("mcpTools: {e}"))?;
|
||||
if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
|
||||
return Err("mcpTools: inputSchema must describe an object".into());
|
||||
}
|
||||
// No HTTP or filesystem resolver features: schemas must be self-contained.
|
||||
jsonschema::validator_for(&schema).map_err(|e| format!("mcpTools: invalid JSON Schema: {e}"))?;
|
||||
let mut out = UiDecl::new(
|
||||
d.name.clone(),
|
||||
d.description.clone(),
|
||||
serde_json::json!({
|
||||
"name": d.name, "description": d.description, "inputSchema": schema, "readOnly": d.read_only, "destructive": d.destructive,
|
||||
}),
|
||||
);
|
||||
out.files.push(d.input_schema);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Live declarations only, refreshed on every list/call. Ambiguous normalized names fail closed.
|
||||
pub fn mcp_tools(&self, root: &std::path::Path) -> Vec<(String, String, ide_mcp::Tool)> {
|
||||
let mut tools = vec![];
|
||||
for item in self.ui.items(POINT, None) {
|
||||
let Ok(plugin) = self.get(&item.plugin_id) else { continue };
|
||||
if !matches!(plugin.status(), PluginStatus::Active | PluginStatus::Inactive)
|
||||
|| plugin.dev_root.as_deref().is_some_and(|r| r != root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let d = &item.decl;
|
||||
let Some(name) = d["name"].as_str() else { continue };
|
||||
tools.push((
|
||||
plugin.id().to_owned(),
|
||||
name.to_owned(),
|
||||
ide_mcp::Tool {
|
||||
name: tool_name(plugin.id(), name),
|
||||
description: format!(
|
||||
"{} (plugin: {} [{}])",
|
||||
d["description"].as_str().unwrap_or_default(),
|
||||
plugin.manifest().name,
|
||||
plugin.id()
|
||||
),
|
||||
input_schema: d["inputSchema"].clone(),
|
||||
output_schema: None,
|
||||
read_only: d["readOnly"].as_bool().unwrap_or(false),
|
||||
destructive: d["destructive"].as_bool().unwrap_or(true),
|
||||
},
|
||||
));
|
||||
}
|
||||
let mut counts = std::collections::HashMap::new();
|
||||
for (_, _, tool) in &tools {
|
||||
*counts.entry(tool.name.clone()).or_insert(0) += 1;
|
||||
}
|
||||
tools.retain(|(_, _, tool)| counts[&tool.name] == 1);
|
||||
tools
|
||||
}
|
||||
pub fn call_mcp_tool(
|
||||
&self,
|
||||
plugin: &str,
|
||||
tool: &str,
|
||||
arguments: &str,
|
||||
invocation: Invocation,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
if arguments.len() > 1 << 20 {
|
||||
return Err(IdeError::invalid("MCP arguments exceed 1 MiB"));
|
||||
}
|
||||
let root =
|
||||
invocation.workspace_root.as_ref().ok_or_else(|| IdeError::invalid("MCP requires a project window"))?;
|
||||
let (_, _, declared) = self
|
||||
.mcp_tools(std::path::Path::new(root))
|
||||
.into_iter()
|
||||
.find(|(p, n, _)| p == plugin && n == tool)
|
||||
.ok_or_else(|| IdeError::not_found("plugin MCP tool is no longer available"))?;
|
||||
let args: serde_json::Value = serde_json::from_str(arguments).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
let validator =
|
||||
jsonschema::validator_for(&declared.input_schema).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if !validator.is_valid(&args) {
|
||||
return Err(IdeError::invalid("MCP arguments do not match inputSchema"));
|
||||
}
|
||||
let (tool, arguments) = (tool.to_owned(), arguments.to_owned());
|
||||
self.code_call_cancellable(plugin, invocation, "calling the MCP tool", Some(cancel), move |rt, inv| {
|
||||
rt.mcp_call(&tool, &arguments, inv)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn validates_names_schema_files_versions_and_schema_keywords_at_install() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let pkg = |api: &str| {
|
||||
Package::new(
|
||||
ide_extension::Manifest::parse(&format!(
|
||||
"id='test.tools'\nname='Tools'\nversion='1.0.0'\nengines.openidea='{api}'\nwasm='plugin.wasm'"
|
||||
))
|
||||
.unwrap(),
|
||||
tmp.path().into(),
|
||||
)
|
||||
};
|
||||
let decl = |name: &str, path: &str| {
|
||||
toml::from_str::<toml::Value>(&format!(
|
||||
"name='{name}'\ndescription='Tool'\ninputSchema='{path}'\nreadOnly=true\ndestructive=false"
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
std::fs::write(
|
||||
tmp.path().join("args.json"),
|
||||
r##"{"type":"object","$defs":{"text":{"type":"string"}},"properties":{"text":{"$ref":"#/$defs/text"}}}"##,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(declaration(&pkg("^1.4"), decl("echo", "args.json")).unwrap().files.len(), 1);
|
||||
assert!(declaration(&pkg("^1.3"), decl("echo", "args.json")).is_err());
|
||||
for name in ["", "Bad", "bad-name", &"x".repeat(41)] {
|
||||
assert!(declaration(&pkg("^1.4"), decl(name, "args.json")).is_err());
|
||||
}
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "../args.json")).is_err());
|
||||
for schema in [
|
||||
r#"{"type":"nonsense"}"#,
|
||||
r#"{"type":"object","required":42}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"file:///private/secret"}}}"#,
|
||||
r#"{"type":"object","properties":{"x":{"$ref":"https://example.invalid/schema"}}}"#,
|
||||
] {
|
||||
std::fs::write(tmp.path().join("args.json"), schema).unwrap();
|
||||
assert!(declaration(&pkg("^1.4"), decl("echo", "args.json")).is_err(), "{schema}");
|
||||
}
|
||||
assert_eq!(tool_name("a.b-c", "echo"), "a_b_c__echo");
|
||||
}
|
||||
}
|
||||
@@ -16,13 +16,10 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
mod auth;
|
||||
pub mod commit;
|
||||
mod database;
|
||||
pub(crate) mod diagnostics;
|
||||
pub mod endpoints;
|
||||
mod event_paths;
|
||||
mod execution;
|
||||
mod files_changed;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
@@ -30,23 +27,17 @@ mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
pub(crate) mod injections;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod ipc;
|
||||
pub mod language_features;
|
||||
mod market;
|
||||
mod mcp;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
pub mod remote_host;
|
||||
mod run_detectors;
|
||||
pub mod run_targets;
|
||||
mod semantic_tokens;
|
||||
mod snippets;
|
||||
mod theme;
|
||||
mod types;
|
||||
pub mod ui_points;
|
||||
@@ -419,11 +410,6 @@ struct PendingUpdate {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Current decoration snapshots for windows opening after a plugin published them.
|
||||
pub fn decoration_sets(&self) -> std::collections::BTreeMap<String, Vec<DecorationSet>> {
|
||||
self.shared.decoration_sets()
|
||||
}
|
||||
|
||||
/// Creates the host; call [`load_all`](Self::load_all) to load installed plugins.
|
||||
pub fn new(plugins_dir: PathBuf, sink: Arc<dyn PluginEventSink>, limits: Limits) -> IdeResult<Self> {
|
||||
std::fs::create_dir_all(&plugins_dir)?;
|
||||
@@ -667,7 +653,6 @@ impl PluginHost {
|
||||
|
||||
/// Unloads the project plugins of `root` (the project became untrusted or closed).
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
self.shared.withdraw_run_targets(None, Some(root));
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
@@ -1095,9 +1080,7 @@ impl PluginHost {
|
||||
ledger.set(&subject, &cap, granted);
|
||||
if granted
|
||||
&& let Capability::Process(program) = &cap
|
||||
&& let Some(bin) = ledger
|
||||
.program_choice(&subject, &cap)
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
&& let Some(bin) = self.resolve_binary(program)
|
||||
{
|
||||
ledger.set_binary(&subject, &cap, bin);
|
||||
}
|
||||
@@ -1114,74 +1097,14 @@ impl PluginHost {
|
||||
/// The program a `process:` grant allows, checked against the file
|
||||
/// approved for it. Errors ask the user to confirm again.
|
||||
pub fn program_for(&self, id: &str, program: &str) -> IdeResult<PathBuf> {
|
||||
self.program_for_project(id, program, None)
|
||||
}
|
||||
|
||||
/// Resolves the approved executable for the invoking project, verifying its pinned content.
|
||||
pub fn program_for_project(&self, id: &str, program: &str, root: Option<&Path>) -> IdeResult<PathBuf> {
|
||||
let plugin = self.get(id)?;
|
||||
let cap = Capability::Process(program.to_owned());
|
||||
self.ledger
|
||||
.lock()
|
||||
.check_binary_in_project(&plugin.subject(), &Capability::Process(program.into()), root)
|
||||
.check_binary(&plugin.subject(), &cap)
|
||||
.map_err(|e| IdeError::new(ide_core::error::ErrorKind::PermissionDenied, e))
|
||||
}
|
||||
|
||||
/// Records a user-approved executable choice; removing it revalidates the fallback or revokes the grant.
|
||||
pub fn choose_program(
|
||||
&self,
|
||||
id: &str,
|
||||
capability: &str,
|
||||
root: Option<&Path>,
|
||||
path: Option<&Path>,
|
||||
) -> IdeResult<PluginInfo> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
self.check_not_uninstalling(&plugin)?;
|
||||
let cap: Capability = capability.parse().map_err(IdeError::invalid)?;
|
||||
let Capability::Process(program) = &cap else {
|
||||
return Err(IdeError::invalid("expected a process capability"));
|
||||
};
|
||||
if !plugin.manifest().declares(&cap) && !plugin.meta.lock().declared_extra.contains(&cap) {
|
||||
return Err(IdeError::invalid("undeclared process capability"));
|
||||
}
|
||||
let binary = if let Some(path) = path {
|
||||
Some(ledger::approve_program(path).map_err(IdeError::invalid)?)
|
||||
} else if root.is_none() {
|
||||
self.resolve_binary(program)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
{
|
||||
let mut ledger = self.ledger.lock();
|
||||
let subject = plugin.subject();
|
||||
if ledger.get(&subject).is_none() {
|
||||
return Err(IdeError::invalid("confirm this plugin's permissions first"));
|
||||
}
|
||||
if let Some(root) = root {
|
||||
ledger.set_project_binary(&subject, &cap, root, binary);
|
||||
if path.is_some() {
|
||||
ledger.set(&subject, &cap, true);
|
||||
} else if ledger.check_binary(&subject, &cap).is_err() {
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
} else if let Some(binary) = binary {
|
||||
ledger.set_program_choice(&subject, &cap, path);
|
||||
ledger.set(&subject, &cap, true);
|
||||
ledger.set_binary(&subject, &cap, binary);
|
||||
} else {
|
||||
ledger.set_program_choice(&subject, &cap, None);
|
||||
ledger.set(&subject, &cap, false);
|
||||
}
|
||||
self.save_ledger(&ledger);
|
||||
}
|
||||
self.stop(&plugin);
|
||||
if !self.state.lock().disabled.contains(id) {
|
||||
self.start(&plugin);
|
||||
}
|
||||
self.changed();
|
||||
self.info_of(id)
|
||||
}
|
||||
|
||||
fn check_uninstall_pending(&self, id: &str) -> IdeResult<()> {
|
||||
if install::uninstall_pending(&self.plugins_dir, id)? {
|
||||
return Err(IdeError::unavailable(std::io::Error::from(std::io::ErrorKind::ResourceBusy).to_string()));
|
||||
@@ -1228,7 +1151,6 @@ impl PluginHost {
|
||||
}
|
||||
self.stop(&plugin);
|
||||
let cleanup = || -> std::io::Result<()> {
|
||||
self.remove_database_sources(id).map_err(|e| std::io::Error::other(e.message))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, &self.shared.data_dir.join(id))?;
|
||||
install::check_cleanup_parent(&self.plugins_dir, plugin.dir())?;
|
||||
install::check_not_symlink(&self.shared.data_dir.join(id))?;
|
||||
@@ -1420,17 +1342,6 @@ impl PluginHost {
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
self.code_call_cancellable(id, invocation, what, None, f)
|
||||
}
|
||||
|
||||
fn code_call_cancellable<R: Send + 'static>(
|
||||
&self,
|
||||
id: &str,
|
||||
invocation: Invocation,
|
||||
what: &str,
|
||||
cancel: Option<&ide_core::CancellationToken>,
|
||||
f: impl Fn(&mut Runtime, Invocation) -> Result<R, CallError> + Clone + Send + 'static,
|
||||
) -> IdeResult<R> {
|
||||
let plugin = self.get(id)?;
|
||||
let client = invocation.client.clone();
|
||||
@@ -1472,14 +1383,7 @@ impl PluginHost {
|
||||
}
|
||||
let started = Instant::now();
|
||||
let (call, inv) = (f.clone(), invocation.clone());
|
||||
let result = match cancel {
|
||||
Some(cancel) => actor.call_cancellable(cancel, Duration::from_millis(50), move |rt| call(rt, inv)),
|
||||
None => actor.call(None, move |rt| call(rt, inv)),
|
||||
};
|
||||
if matches!(result, Err(ActorError::Cancelled)) {
|
||||
self.reap(&plugin, &actor, client.as_deref());
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let result = actor.call(None, move |rt| call(rt, inv));
|
||||
if !matches!(result, Err(ActorError::Stopped | ActorError::SoftTimeout | ActorError::Cancelled)) {
|
||||
let crashed = matches!(result, Err(ActorError::Call(CallError::Trap(_))));
|
||||
// Cancelling or a missing grant is not the plugin failing.
|
||||
@@ -1553,12 +1457,7 @@ impl PluginHost {
|
||||
|
||||
/// Plugins' `runDetectors`, for [`ide_run::set_external_detection`].
|
||||
pub fn run_detection(&self) -> Arc<dyn ide_run::ExternalDetection> {
|
||||
Arc::new(run_detectors::PluginRunDetection {
|
||||
store: self.run_detectors.clone(),
|
||||
dynamic: self.shared.run_configurations.clone(),
|
||||
pins: self.pins.clone(),
|
||||
project_pins: self.shared.project_pins.clone(),
|
||||
})
|
||||
Arc::new(run_detectors::PluginRunDetection { store: self.run_detectors.clone(), pins: self.pins.clone() })
|
||||
}
|
||||
|
||||
/// The syntax of plugin languages, for [`ide_language::set_external_syntax`].
|
||||
@@ -1614,10 +1513,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Plugin context keys in effect for window `client`: global keys overlaid with its own.
|
||||
pub fn status_snapshot(&self, client: &str) -> Vec<PluginStatusItem> {
|
||||
self.shared.status_snapshot(client)
|
||||
}
|
||||
|
||||
pub fn context_keys(&self, client: Option<&str>) -> Vec<PluginContextKey> {
|
||||
self.shared.context_keys(client)
|
||||
}
|
||||
@@ -1648,10 +1543,6 @@ impl PluginHost {
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_command(&self, client: &str, request: &str, answer: CommandAnswer) -> bool {
|
||||
request.parse().is_ok_and(|id| self.shared.answer_command(client, id, answer))
|
||||
}
|
||||
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
@@ -1741,30 +1632,6 @@ impl PluginHost {
|
||||
|
||||
/// Queues workbench events from window `client` for the plugins that want
|
||||
/// them; [`flush_events`](Self::flush_events) delivers them in batches.
|
||||
pub fn post_window_events(&self, client: &str, events: Vec<WorkbenchEvent>, editor: Option<PluginEditor>) {
|
||||
let mut editors = self.shared.event_editors.write();
|
||||
if let Some(editor) = editor {
|
||||
editors.insert(client.into(), editor.context());
|
||||
} else {
|
||||
editors.remove(client);
|
||||
}
|
||||
drop(editors);
|
||||
self.post_events(
|
||||
Some(client),
|
||||
events
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
!matches!(
|
||||
e,
|
||||
WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn post_events(&self, client: Option<&str>, events: Vec<WorkbenchEvent>) {
|
||||
self.post_events_from(client, events, None);
|
||||
}
|
||||
@@ -1794,13 +1661,7 @@ impl PluginHost {
|
||||
.as_ref()
|
||||
.is_none_or(|root| client.and_then(|c| self.shared.project_of(c)).as_ref() == Some(root))
|
||||
})
|
||||
.filter_map(|e| match e {
|
||||
WorkbenchEvent::SettingsChanged(_) => {
|
||||
let keys = self.shared.changed_settings(&p.pkg, client);
|
||||
(!keys.is_empty()).then_some(WorkbenchEvent::SettingsChanged(keys))
|
||||
}
|
||||
_ => Some(e.clone()),
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let wanted = Self::filter_path_events(&self.shared, &p, client, &wanted, &subs);
|
||||
if wanted.is_empty() {
|
||||
@@ -1821,15 +1682,7 @@ impl PluginHost {
|
||||
let excess = queue.events.len().saturating_sub(MAX_QUEUED_EVENTS);
|
||||
let mut remaining = excess;
|
||||
queue.events.retain(|event| {
|
||||
if remaining > 0
|
||||
&& !matches!(
|
||||
event,
|
||||
WorkbenchEvent::FilesChanged(_)
|
||||
| WorkbenchEvent::RunExited(_)
|
||||
| WorkbenchEvent::TerminalExited(_)
|
||||
| WorkbenchEvent::ContentTabAction(_)
|
||||
)
|
||||
{
|
||||
if remaining > 0 && !matches!(event, WorkbenchEvent::FilesChanged(_)) {
|
||||
remaining -= 1;
|
||||
false
|
||||
} else {
|
||||
@@ -1956,10 +1809,9 @@ impl PluginHost {
|
||||
let invocation = Invocation {
|
||||
workspace_root: client.as_deref().and_then(|c| self.shared.project_of(c)),
|
||||
client: client.clone(),
|
||||
editor: client.as_deref().and_then(|c| self.shared.event_editors.read().get(c).cloned()),
|
||||
editor: None,
|
||||
selected_paths: Vec::new(),
|
||||
global_settings: false,
|
||||
command_chain: Vec::new(),
|
||||
};
|
||||
let kinds: BTreeSet<EventKind> = queue.events.iter().map(WorkbenchEvent::kind).collect();
|
||||
let filesystem = kinds.contains(&EventKind::FilesChanged);
|
||||
@@ -1971,9 +1823,6 @@ impl PluginHost {
|
||||
if batches.running.as_ref() == Some(&kinds)
|
||||
&& !kinds.contains(&EventKind::Scheduled)
|
||||
&& !kinds.contains(&EventKind::FilesChanged)
|
||||
&& !kinds.iter().any(|k| {
|
||||
matches!(k, EventKind::RunExited | EventKind::TerminalExited | EventKind::ContentTabAction)
|
||||
})
|
||||
{
|
||||
actor.cancel_soft();
|
||||
}
|
||||
@@ -2750,21 +2599,8 @@ impl PluginHost {
|
||||
/// pick up a change on their next lookup.
|
||||
fn refresh_pins(&self, ledger: &Ledger) {
|
||||
let mut pins = HashMap::new();
|
||||
let mut project_pins = HashMap::new();
|
||||
for plugin in self.plugins.read().values() {
|
||||
let subject = plugin.subject();
|
||||
if let Some(grant) = ledger.get(&subject) {
|
||||
for (root, binaries) in &grant.project_binaries {
|
||||
for (cap, bin) in binaries {
|
||||
if grant.granted.contains(cap) {
|
||||
project_pins.insert(
|
||||
(plugin.id().to_owned(), cap.clone(), root.clone()),
|
||||
ProgramPin { path: bin.path.clone(), hash: bin.hash.clone() },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let declared =
|
||||
plugin.manifest().capabilities.all().cloned().chain(plugin.meta.lock().declared_extra.clone());
|
||||
for cap in declared.filter(|c| matches!(c, Capability::Process(_))) {
|
||||
@@ -2776,7 +2612,6 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
}
|
||||
*self.shared.project_pins.write() = project_pins;
|
||||
let mut current = self.pins.write();
|
||||
if *current != pins {
|
||||
*current = pins;
|
||||
@@ -2830,26 +2665,12 @@ impl PluginHost {
|
||||
|
||||
fn record_consent(&self, request: &Request<'_>, consent: &Consent) {
|
||||
let accepted: Vec<Capability> = request.optional.iter().filter(|c| consent.accepts(c)).cloned().collect();
|
||||
let choices: BTreeMap<_, _> = {
|
||||
let ledger = self.ledger.lock();
|
||||
request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|cap| {
|
||||
ledger.program_choice(&request.subject, cap).map(|p| (cap.to_string(), p.to_path_buf()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let binaries: BTreeMap<String, BinaryGrant> = request
|
||||
.required
|
||||
.iter()
|
||||
.chain(&accepted)
|
||||
.filter_map(|c| match c {
|
||||
Capability::Process(program) => choices
|
||||
.get(&c.to_string())
|
||||
.map_or_else(|| self.resolve_binary(program), |path| ledger::approve_program(path).ok())
|
||||
.map(|b| (c.to_string(), b)),
|
||||
Capability::Process(program) => self.resolve_binary(program).map(|b| (c.to_string(), b)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
Loaded 100 of 890 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user