mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb258b0912 |
No files matched your search
@@ -1,52 +0,0 @@
|
||||
name: CNB mirror
|
||||
|
||||
# Copies a published release to cnb.cool/l0ng-ai/openidea, the updater's CNB source
|
||||
# (scripts/cnb-mirror.ts). Release runs it after publishing; run it by hand to mirror
|
||||
# an existing tag again. Skipped without the CNB_TOKEN secret.
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag: { type: string, required: true }
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Published release to mirror (e.g. v0.1.38)"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: cnb-mirror-${{ inputs.tag }}
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
env:
|
||||
TAG: ${{ inputs.tag }}
|
||||
CNB_TOKEN: ${{ secrets.CNB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
steps:
|
||||
- name: Check token
|
||||
id: token
|
||||
run: |
|
||||
if [ -z "$CNB_TOKEN" ]; then
|
||||
echo "::notice::CNB_TOKEN unset: not mirroring $TAG"
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- uses: actions/checkout@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { sparse-checkout: scripts }
|
||||
- uses: actions/setup-node@v5
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
with: { node-version: 22 }
|
||||
- name: Download release
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: |
|
||||
gh release download "$TAG" -R "$GITHUB_REPOSITORY" -D assets
|
||||
gh release view "$TAG" -R "$GITHUB_REPOSITORY" --json body,isPrerelease > assets/release.json
|
||||
du -sh assets
|
||||
- name: Upload to CNB
|
||||
if: steps.token.outputs.skip != 'true'
|
||||
run: node scripts/cnb-mirror.ts "$TAG" assets
|
||||
@@ -355,10 +355,3 @@ jobs:
|
||||
await github.rest.repos.updateRelease({
|
||||
...context.repo, release_id: Number(process.env.RELEASE_ID), draft: false,
|
||||
});
|
||||
|
||||
# The China mirror the updater's CNB source reads; a failure here leaves the GitHub release as is.
|
||||
cnb-mirror:
|
||||
needs: publish
|
||||
uses: ./.github/workflows/cnb-mirror.yml
|
||||
with: { tag: "${{ github.event.inputs.tag || github.ref_name }}" }
|
||||
secrets: inherit
|
||||
@@ -11,7 +11,6 @@
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/jsp/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"docs/plugins/themes.md",
|
||||
"pnpm-lock.yaml",
|
||||
"**/*.toml",
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
"crates",
|
||||
"src/bindings.ts",
|
||||
"src/editor/core/cm/angular/parser*.ts",
|
||||
"src/editor/core/cm/astro/parser*.ts",
|
||||
"fixtures",
|
||||
"plugins",
|
||||
"test-results",
|
||||
|
||||
@@ -79,8 +79,6 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
benches vs budgets). Budgets: `perf/budgets.json`.
|
||||
- `pnpm release:prepare <version>` — the release commit's changes: app version everywhere,
|
||||
`Cargo.lock`, and the plugin API snapshot when `wit/` moved to a new version.
|
||||
|
||||
## Before opening a PR
|
||||
|
||||
|
||||
Generated
+5
-82
@@ -1552,16 +1552,6 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"darling_macro 0.13.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@@ -1582,20 +1572,6 @@ dependencies = [
|
||||
"darling_macro 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.10.0",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_core"
|
||||
version = "0.23.0"
|
||||
@@ -1605,7 +1581,7 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
@@ -1618,21 +1594,10 @@ dependencies = [
|
||||
"ident_case",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.13.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835"
|
||||
dependencies = [
|
||||
"darling_core 0.13.4",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling_macro"
|
||||
version = "0.23.0"
|
||||
@@ -4212,7 +4177,6 @@ dependencies = [
|
||||
name = "ide-dap"
|
||||
version = "0.1.37"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
@@ -4238,7 +4202,6 @@ dependencies = [
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"oracle",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
@@ -6013,6 +5976,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2 0.6.2",
|
||||
"dispatch2",
|
||||
"objc2 0.6.5",
|
||||
]
|
||||
@@ -6323,15 +6287,6 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "odpic-sys"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "920b5474a5128a9f0232df5a0ffc50aaa5b077b29b8b06ab0131985ac82793ed"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -6403,6 +6358,7 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-web-kit 0.3.2",
|
||||
"parking_lot",
|
||||
@@ -6442,32 +6398,6 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "oracle"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3db40fe6e4df881b683691ade5ef1f7b1afd52aefa115581f7b92855524d7ec0"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"odpic-sys",
|
||||
"once_cell",
|
||||
"oracle_procmacro",
|
||||
"paste",
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oracle_procmacro"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ad247f3421d57de56a0d0408d3249d4b1048a522be2013656d92f022c3d8af27"
|
||||
dependencies = [
|
||||
"darling 0.13.4",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
@@ -8768,12 +8698,6 @@ dependencies = [
|
||||
"unicode-properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
@@ -8810,7 +8734,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
@@ -9033,7 +8956,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"static_vcruntime",
|
||||
"strsim 0.11.1",
|
||||
"strsim",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"walkdir",
|
||||
|
||||
@@ -103,7 +103,6 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "std"
|
||||
rustls-platform-verifier = "0.7"
|
||||
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring-webpki", "sqlite", "postgres", "mysql"] }
|
||||
tiberius = { version = "0.13", default-features = false, features = ["tds73", "rustls", "sql-browser-tokio"] }
|
||||
oracle = { version = "0.6", default-features = false }
|
||||
bb8 = "0.9"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
# OS credential stores
|
||||
|
||||
@@ -68,7 +68,4 @@ the [official plugins](plugins/official). The plugin API is still a preview.
|
||||
|
||||
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
|
||||
|
||||
Database tool window notes, including Oracle Instant Client setup, are in
|
||||
[`docs/databases.md`](docs/databases.md).
|
||||
|
||||
<sub>Not affiliated with JetBrains. IntelliJ IDEA is a trademark of JetBrains s.r.o.</sub>
|
||||
@@ -2,9 +2,8 @@
|
||||
|
||||
## Project Lombok
|
||||
|
||||
`java/lombok/lombok-1.18.48-openidea.3.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059),
|
||||
the [compact record constructor builder fix](java/lombok/patches/builder-compact-constructor.patch)
|
||||
`java/lombok/lombok-1.18.48-openidea.2.jar` contains Project Lombok v1.18.48 with the Equinox
|
||||
classloader fix from [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059)
|
||||
and the ECJ constructor-call fix from [projectlombok/lombok#4089](https://github.com/projectlombok/lombok/pull/4089).
|
||||
Copyright (C) 2009-2021 The Project Lombok Authors. MIT license and bundled component
|
||||
notices are retained in [java/lombok/LICENSE](java/lombok/LICENSE) and inside the jar.
|
||||
@@ -34,8 +33,7 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
carries its own licence): `protobuf/protoeditor-core` (`newui/protoFile` →
|
||||
`fileTypes/protoFile`, Apache-2.0); `terraform` (`newui/hashiCorp` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `Astro` (`icons/astro` → `fileTypes/astro`,
|
||||
Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/hashiCorp`, Apache-2.0); `makefile` (`expui/makefile` →
|
||||
`fileTypes/makefile`, MIT, Copyright (c) 2016 Victor Kropp — full text below).
|
||||
|
||||
The read/write usage filter assets (`actions/showReadAccess{,_dark}.svg` and
|
||||
|
||||
@@ -882,8 +882,7 @@ mod tests {
|
||||
model.gradle[0].env.insert("JAVA_HOME".into(), "/external-jdk".into());
|
||||
let all = super::plan(&root, &model, super::BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(all.steps[0].cwd, external);
|
||||
// Linked roots are saved with forward slashes, so on Windows the program mixes separators.
|
||||
assert_eq!(std::path::Path::new(&all.steps[0].program), external.join(wrapper));
|
||||
assert_eq!(all.steps[0].program, external.join(wrapper).to_string_lossy());
|
||||
let module = super::plan(&root, &model, super::BuildKind::Rebuild, Some(&source), None).unwrap();
|
||||
assert_eq!(module.steps[0].cwd, external);
|
||||
assert_eq!(module.steps[0].env["JAVA_HOME"], "/external-jdk");
|
||||
|
||||
@@ -6,7 +6,6 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
futures.workspace = true
|
||||
tempfile.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-language.workspace = true
|
||||
|
||||
+23
-50
@@ -19,7 +19,6 @@ mod session;
|
||||
|
||||
use adapter::{AdapterLauncher, AdapterSpec, ProcessLauncher};
|
||||
use connection::{Connection, DapError};
|
||||
use futures::future::join_all;
|
||||
use ide_core::{DebugSessionId, IdeError, IdeResult, SharedSink};
|
||||
use ide_language::LanguageRegistry;
|
||||
use parking_lot::Mutex;
|
||||
@@ -511,18 +510,6 @@ pub(crate) fn capabilities_from(caps: &Value) -> DebugCapabilities {
|
||||
}
|
||||
}
|
||||
|
||||
/// States of a file's line breakpoints that no session has been sent.
|
||||
fn unsent(breakpoints: &[Breakpoint]) -> Vec<BreakpointState> {
|
||||
breakpoints
|
||||
.iter()
|
||||
.map(|b| BreakpointState {
|
||||
line: b.line,
|
||||
verified: false,
|
||||
message: (!b.enabled).then(|| "Disabled".to_owned()),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// States for breakpoints no session has verified yet.
|
||||
fn unverified(enabled: impl Iterator<Item = bool>) -> Vec<BreakpointState> {
|
||||
enabled
|
||||
@@ -890,20 +877,12 @@ impl DebugManager {
|
||||
let _ = tokio::time::timeout(INIT_TIMEOUT, initialized.wait_for(|v| *v)).await;
|
||||
}
|
||||
|
||||
// A fresh adapter has no breakpoints, so a file with none enabled needs no request; the
|
||||
// others go out together, as VS Code sends them, instead of one round trip per file.
|
||||
let (files, idle): (Vec<_>, Vec<_>) = self
|
||||
.breakpoints
|
||||
.lock()
|
||||
.iter()
|
||||
.map(|(p, b)| (p.clone(), b.clone()))
|
||||
.partition(|(_, b)| b.iter().any(|b| b.enabled));
|
||||
let sent = join_all(files.iter().map(|(path, bps)| s.send_breakpoints(path, bps, None))).await;
|
||||
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
|
||||
self.breakpoints.lock().iter().map(|(p, b)| (p.clone(), b.clone())).collect();
|
||||
// Breakpoint states follow the owner's first session.
|
||||
let first = self.live_sessions().into_iter().find(|f| f.owner.key == s.owner.key).map(|f| f.id) == Some(s.id);
|
||||
let idle = idle.into_iter().map(|(path, bps)| (path, Ok(unsent(&bps))));
|
||||
for (path, result) in files.into_iter().map(|(path, _)| path).zip(sent).chain(idle) {
|
||||
match result {
|
||||
for (path, bps) in files {
|
||||
match s.send_breakpoints(&path, &bps, None).await {
|
||||
Ok(states) => {
|
||||
if first {
|
||||
self.states.lock().insert(path.clone(), states);
|
||||
@@ -1011,7 +990,16 @@ impl DebugManager {
|
||||
Err(e) => tracing::debug!("setBreakpoints on session {} failed: {e}", s.id.0),
|
||||
}
|
||||
}
|
||||
let states = result.unwrap_or_else(|| unsent(&breakpoints));
|
||||
let states = result.unwrap_or_else(|| {
|
||||
breakpoints
|
||||
.iter()
|
||||
.map(|b| BreakpointState {
|
||||
line: b.line,
|
||||
verified: false,
|
||||
message: (!b.enabled).then(|| "Disabled".to_owned()),
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
self.states.lock().insert(path, states.clone());
|
||||
Ok(states)
|
||||
}
|
||||
@@ -1339,26 +1327,13 @@ impl DebugManager {
|
||||
/// Sends empty breakpoint lists (line, function, data) to `session` until its next
|
||||
/// stop, when the stored ones are restored. `except` is left to the caller.
|
||||
async fn suspend_breakpoints(&self, session: &Arc<Session>, except: Option<&Path>) -> IdeResult<()> {
|
||||
// While paused the adapter holds the stored lists: only files with something enabled change.
|
||||
let files: Vec<PathBuf> = self
|
||||
.breakpoints
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|(path, bps)| Some(path.as_path()) != except && bps.iter().any(|b| b.enabled))
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect();
|
||||
let sent = join_all(files.iter().map(|path| session.send_breakpoints(path, &[], None))).await;
|
||||
let mut failed = None;
|
||||
for (path, result) in files.into_iter().zip(sent) {
|
||||
match result {
|
||||
Ok(_) => {
|
||||
session.pending_restore.lock().files.insert(path);
|
||||
}
|
||||
Err(e) => failed = failed.or(Some(e)),
|
||||
let files: Vec<PathBuf> = self.breakpoints.lock().keys().cloned().collect();
|
||||
for path in files {
|
||||
if Some(path.as_path()) == except {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if let Some(e) = failed {
|
||||
return Err(e.into());
|
||||
session.send_breakpoints(&path, &[], None).await?;
|
||||
session.pending_restore.lock().files.insert(path);
|
||||
}
|
||||
if !self.function_breakpoints.lock().is_empty() && session.supports("supportsFunctionBreakpoints") {
|
||||
session.send_function_breakpoints(&[]).await?;
|
||||
@@ -1374,11 +1349,9 @@ impl DebugManager {
|
||||
/// Re-sends what [`suspend_breakpoints`](Self::suspend_breakpoints) / run to cursor replaced.
|
||||
pub(crate) async fn restore_breakpoints(&self, session: &Arc<Session>) {
|
||||
let pending = std::mem::take(&mut *session.pending_restore.lock());
|
||||
let files: Vec<(PathBuf, Vec<Breakpoint>)> =
|
||||
pending.files.into_iter().map(|path| (path.clone(), self.stored_breakpoints(&path))).collect();
|
||||
let sent = join_all(files.iter().map(|(path, bps)| session.send_breakpoints(path, bps, None))).await;
|
||||
for ((path, _), result) in files.iter().zip(sent) {
|
||||
if let Err(e) = result {
|
||||
for path in pending.files {
|
||||
let bps = self.stored_breakpoints(&path);
|
||||
if let Err(e) = session.send_breakpoints(&path, &bps, None).await {
|
||||
tracing::debug!("restoring breakpoints of {} failed: {e}", path.display());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@ struct Script {
|
||||
start_debugging: bool,
|
||||
ignore_configuration_done: bool,
|
||||
source_path: Option<String>,
|
||||
/// Answer `setBreakpoints` only once this many are waiting (a client that waits for each never gets one).
|
||||
hold_set_breakpoints: usize,
|
||||
}
|
||||
|
||||
struct FakeLauncher {
|
||||
@@ -51,7 +49,6 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
let mut r = BufReader::new(r);
|
||||
let mut seq = 1000;
|
||||
let mut pending_launch: Option<i64> = None;
|
||||
let mut held: Vec<Value> = Vec::new();
|
||||
macro_rules! send {
|
||||
($v:expr) => {{
|
||||
let mut v: Value = $v;
|
||||
@@ -104,20 +101,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
send!(json!({"type": "event", "event": "initialized"}));
|
||||
}
|
||||
"setBreakpoints" => {
|
||||
held.push(msg);
|
||||
if held.len() < script.hold_set_breakpoints {
|
||||
continue;
|
||||
}
|
||||
for msg in std::mem::take(&mut held) {
|
||||
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
|
||||
.collect();
|
||||
ok!(msg, json!({"breakpoints": bps}));
|
||||
}
|
||||
let bps: Vec<Value> = msg["arguments"]["breakpoints"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, b)| json!({"id": i + 1, "verified": true, "line": b["line"]}))
|
||||
.collect();
|
||||
ok!(msg, json!({"breakpoints": bps}));
|
||||
}
|
||||
"setExceptionBreakpoints" => ok!(msg, json!({})),
|
||||
"setFunctionBreakpoints" | "setDataBreakpoints" => {
|
||||
@@ -930,7 +921,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
|
||||
let b = PathBuf::from("/p/b.py");
|
||||
h.manager.set_breakpoints(a.clone(), vec![bp(1)]).await.unwrap();
|
||||
h.manager.set_breakpoints(b.clone(), vec![bp(3)]).await.unwrap();
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/off.py"), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
|
||||
h.manager.set_function_breakpoints(vec![function_bp("run", true)]).await.unwrap();
|
||||
let id = h.start(config()).await.unwrap();
|
||||
h.wait_event(|e| matches!(e, DebugEvent::Stopped { .. })).await;
|
||||
@@ -959,24 +949,6 @@ async fn force_stepping_suspends_breakpoints_until_the_next_stop() {
|
||||
h.manager.run_to(id, 1, a.clone(), 9, true).await.unwrap();
|
||||
assert_eq!(sent_for("/p/a.py").last().unwrap(), &json!([{"line": 10}]));
|
||||
assert_eq!(sent_for("/p/b.py").last().unwrap(), &json!([]));
|
||||
assert!(sent_for("/p/off.py").is_empty(), "nothing enabled there, nothing to suspend or restore");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn startup_sends_every_file_at_once_and_skips_files_with_nothing_enabled() {
|
||||
let h = harness(Script { hold_set_breakpoints: 2, ..Script::default() });
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/a.py"), vec![bp(1)]).await.unwrap();
|
||||
h.manager.set_breakpoints(PathBuf::from("/p/b.py"), vec![bp(3)]).await.unwrap();
|
||||
let off = PathBuf::from("/p/off.py");
|
||||
h.manager.set_breakpoints(off.clone(), vec![Breakpoint { enabled: false, ..bp(5) }]).await.unwrap();
|
||||
// Waiting for each answer before the next request would stall until the request timeout.
|
||||
tokio::time::timeout(Duration::from_secs(5), h.start(config())).await.expect("requests pipelined").unwrap();
|
||||
let paths: Vec<Value> = requests(&h, "setBreakpoints").into_iter().map(|r| r["source"]["path"].clone()).collect();
|
||||
assert_eq!(paths, [json!("/p/a.py"), json!("/p/b.py")]);
|
||||
assert_eq!(
|
||||
h.manager.breakpoint_states(&off),
|
||||
[BreakpointState { line: 5, verified: false, message: Some("Disabled".into()) }]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -17,7 +17,6 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
tiberius.workspace = true
|
||||
oracle.workspace = true
|
||||
bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
|
||||
@@ -107,7 +107,6 @@ pub enum Pool {
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OraclePool),
|
||||
}
|
||||
|
||||
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
|
||||
@@ -117,7 +116,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) | Pool::Oracle(_) => unreachable!(),
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -263,7 +262,7 @@ fn tiberius_cell(cell: &tiberius::ColumnData<'_>, ty: tiberius::ColumnType) -> O
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn hex(bytes: &[u8]) -> String {
|
||||
fn hex(bytes: &[u8]) -> String {
|
||||
let mut s = String::with_capacity(2 + bytes.len() * 2);
|
||||
s.push_str("0x");
|
||||
for b in bytes {
|
||||
@@ -387,7 +386,7 @@ macro_rules! fetch {
|
||||
}
|
||||
|
||||
/// Columns, rows, rows affected, more rows beyond the limit, time of the first answer.
|
||||
pub(crate) type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
type Fetched = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, Option<u64>, bool, Option<Instant>);
|
||||
|
||||
async fn mssql_command(conn: &mut MssqlClient, sql: &str) -> IdeResult<()> {
|
||||
// Transaction control must be a batch: sp_executesql rejects a changed transaction count.
|
||||
@@ -504,18 +503,6 @@ impl Setup {
|
||||
}
|
||||
}
|
||||
DbDriver::Mysql if source.read_only => s.statements.push("SET SESSION TRANSACTION READ ONLY".into()),
|
||||
DbDriver::Oracle => {
|
||||
let schema = o.default_schema.trim();
|
||||
if !schema.is_empty() {
|
||||
if !is_identifier(schema) {
|
||||
return Err(IdeError::invalid(format!("invalid schema name: {schema}")));
|
||||
}
|
||||
s.statements.push(format!("ALTER SESSION SET CURRENT_SCHEMA = {schema}"));
|
||||
}
|
||||
if !o.time_zone.trim().is_empty() {
|
||||
s.statements.push(format!("ALTER SESSION SET TIME_ZONE = {}", mysql_value(o.time_zone.trim())));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
for p in &source.properties {
|
||||
@@ -540,9 +527,6 @@ impl Setup {
|
||||
(DbDriver::Mysql, "charset") => s.charset = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, "collation") => s.collation = Some(value.to_owned()),
|
||||
(DbDriver::Mysql, _) => s.statements.push(format!("SET SESSION {name} = {}", mysql_value(value))),
|
||||
(DbDriver::Oracle, _) => {
|
||||
s.statements.push(format!("ALTER SESSION SET {name} = {}", mysql_value(value)))
|
||||
}
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
@@ -565,15 +549,6 @@ impl Pool {
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
Pool::Oracle(_) => DbDriver::Oracle,
|
||||
}
|
||||
}
|
||||
|
||||
/// The keep-alive ping (Oracle needs a FROM clause).
|
||||
pub fn ping_sql(&self) -> &'static str {
|
||||
match self {
|
||||
Pool::Oracle(_) => "SELECT 1 FROM DUAL",
|
||||
_ => "SELECT 1",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -584,9 +559,6 @@ impl Pool {
|
||||
if source.driver == DbDriver::Clickhouse {
|
||||
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
|
||||
}
|
||||
if source.driver == DbDriver::Oracle {
|
||||
return crate::oracle::OraclePool::connect(source, password, &setup).await.map(Pool::Oracle);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
@@ -749,13 +721,12 @@ impl Pool {
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
DbDriver::Oracle => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
match self {
|
||||
Pool::SqlServer(_) | Pool::Oracle(_) => {}
|
||||
Pool::SqlServer(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
@@ -764,7 +735,6 @@ impl Pool {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Oracle(p) => p.is_closed(),
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
@@ -775,11 +745,6 @@ impl Pool {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
if let Pool::Oracle(p) = self {
|
||||
let mut conn = p.get().await?;
|
||||
let fetched = crate::oracle::run(&mut conn, sql, 0, max, Cells::Text, &CancellationToken::new()).await?;
|
||||
return Ok(fetched.1);
|
||||
}
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
@@ -846,15 +811,6 @@ impl Pool {
|
||||
}
|
||||
result
|
||||
}
|
||||
Pool::Oracle(p) => {
|
||||
let mut conn = p.get().await?;
|
||||
let result = crate::oracle::transaction(&mut conn, statements).await;
|
||||
if result.is_err() {
|
||||
// An open transaction must never return to the pool with the connection.
|
||||
conn.discard_inner();
|
||||
}
|
||||
result
|
||||
}
|
||||
_ => with_pool!(self, p => {
|
||||
let mut tx = p.begin().await.map_err(db_err)?;
|
||||
let mut total = 0;
|
||||
@@ -882,7 +838,6 @@ pub enum Held {
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
Oracle(crate::oracle::OracleConnection),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -898,7 +853,6 @@ impl Held {
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
Pool::Oracle(p) => Held::Oracle(p.get().await?),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -907,20 +861,13 @@ impl Held {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Held::Oracle(c) => {
|
||||
let sql = sql.trim();
|
||||
if sql.is_empty() {
|
||||
return Ok(()); // Oracle transactions open implicitly.
|
||||
}
|
||||
crate::oracle::run(c, sql, 0, 0, Cells::Text, &CancellationToken::new()).await.map(drop)
|
||||
}
|
||||
other => {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match other {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) | Held::Oracle(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -942,7 +889,6 @@ impl Held {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
Held::Oracle(mut c) => c.discard_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1013,7 +959,6 @@ impl Held {
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
Held::Oracle(c) => crate::oracle::run(c, sql, skip, max_rows, Cells::Display, cancel).await?,
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
@@ -1035,7 +980,6 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Oracle => "", // opens implicitly with autocommit off
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
mod oracle;
|
||||
pub mod plan;
|
||||
pub mod secrets;
|
||||
pub mod source;
|
||||
@@ -304,7 +303,7 @@ fn keep_alive(pool: &Arc<Pool>, every: Duration) {
|
||||
loop {
|
||||
tick.tick().await;
|
||||
let Some(p) = pool.upgrade().filter(|p| !p.is_closed()) else { break };
|
||||
if let Err(e) = p.rows(p.ping_sql(), 1).await {
|
||||
if let Err(e) = p.rows("SELECT 1", 1).await {
|
||||
tracing::debug!("keep-alive: {}", e.message);
|
||||
}
|
||||
}
|
||||
@@ -615,7 +614,6 @@ impl DbManager {
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
DbDriver::Oracle => "SELECT banner FROM v$version WHERE banner LIKE 'Oracle%' AND ROWNUM = 1",
|
||||
};
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
@@ -907,33 +905,6 @@ impl DbManager {
|
||||
.next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
let (s, n) = (sql::quote_literal(driver, schema), sql::quote_literal(driver, name));
|
||||
let kind = pool
|
||||
.rows(
|
||||
&format!(
|
||||
"SELECT object_type FROM all_objects WHERE owner = {s} AND object_name = {n} \
|
||||
AND object_type IN ('TABLE', 'VIEW') AND ROWNUM = 1"
|
||||
),
|
||||
1,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|r| r.into_iter().next().flatten())
|
||||
.ok_or_else(missing)?;
|
||||
let ddl = cells(
|
||||
pool.rows(
|
||||
&format!("SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL", kind.replace('\'', "''")),
|
||||
1,
|
||||
)
|
||||
.await?,
|
||||
0,
|
||||
)
|
||||
.into_iter()
|
||||
.next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
let rel = pool.rows(&sql::postgres_relation(schema, name), 1).await?.into_iter().next();
|
||||
let (oid, kind) = match rel.as_deref() {
|
||||
@@ -1086,29 +1057,6 @@ impl DbManager {
|
||||
/// The execution plan of `statement` (explained, not run).
|
||||
pub async fn explain(&self, id: &str, statement: &str, cancel: &CancellationToken) -> IdeResult<Vec<PlanNode>> {
|
||||
let driver = self.pool(id).await?.driver();
|
||||
if driver == DbDriver::Oracle {
|
||||
// EXPLAIN PLAN writes PLAN_TABLE; the plan is read (and cleaned up) separately.
|
||||
let plan_id = format!(
|
||||
"OPENIDEA{}",
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.subsec_nanos())
|
||||
);
|
||||
self.run(
|
||||
id,
|
||||
&format!(
|
||||
"EXPLAIN PLAN SET STATEMENT_ID = '{}' FOR {}",
|
||||
plan_id,
|
||||
statement.trim().trim_end_matches(';')
|
||||
),
|
||||
0,
|
||||
MAX_META_ROWS,
|
||||
cancel,
|
||||
)
|
||||
.await?;
|
||||
let result = self.run(id, &plan::oracle_plan_query(&plan_id), 0, MAX_META_ROWS, cancel).await?;
|
||||
let nodes = plan::parse(driver, &result);
|
||||
let _ = self.run(id, &plan::oracle_plan_cleanup(&plan_id), 0, 0, cancel).await;
|
||||
return nodes;
|
||||
}
|
||||
let result = self.run(id, &plan::explain_statement(driver, statement), 0, MAX_META_ROWS, cancel).await?;
|
||||
plan::parse(driver, &result)
|
||||
}
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
//! Oracle connections through the synchronous `rust-oracle` driver (ODPI-C).
|
||||
//! The Oracle client library (Instant Client) is loaded once per process from
|
||||
//! the Options tab or `ORACLE_HOME`; every blocking call runs on the blocking
|
||||
//! pool, and cancelling a statement closes its connection.
|
||||
|
||||
use crate::conn::{Cells, Fetched, ResultColumn, Setup, hex};
|
||||
use crate::source::{DataSource, DbDriver, DbTxMode};
|
||||
use bb8::ManageConnection;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use oracle::sql_type::{Blob, Clob, OracleType};
|
||||
use std::io::Read;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// Characters of a LOB column shown in the grid; catalog text reads more.
|
||||
const MAX_GRID_LOB: u64 = 4096;
|
||||
const MAX_TEXT_LOB: u64 = 1 << 16;
|
||||
|
||||
/// Everything a new connection needs, built once per pool.
|
||||
#[derive(Debug)]
|
||||
struct State {
|
||||
user: String,
|
||||
password: String,
|
||||
/// EZConnect `//host:port/service`, a TNS descriptor or an alias.
|
||||
connect: String,
|
||||
init: Vec<String>,
|
||||
auto_commit: bool,
|
||||
read_only: bool,
|
||||
statement_cache: Option<u32>,
|
||||
timeout: Duration,
|
||||
}
|
||||
|
||||
impl State {
|
||||
fn of(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
let user = source.user.trim();
|
||||
if user.is_empty() {
|
||||
return Err(IdeError::invalid("Oracle data sources need a user name"));
|
||||
}
|
||||
Ok(Self {
|
||||
user: user.to_owned(),
|
||||
password: password.unwrap_or_default().to_owned(),
|
||||
connect: connect_string(source),
|
||||
init: setup.statements.clone(),
|
||||
auto_commit: source.options.transaction != DbTxMode::Manual,
|
||||
read_only: source.read_only,
|
||||
statement_cache: setup.statement_cache.map(|n| n as u32),
|
||||
timeout: setup.connect_timeout.unwrap_or(Duration::from_secs(30)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `//host:port/service`; the database field may also hold a TNS descriptor or alias.
|
||||
fn connect_string(source: &DataSource) -> String {
|
||||
let db = source.database.trim();
|
||||
if db.starts_with('(') || db.contains('=') {
|
||||
return db.to_owned();
|
||||
}
|
||||
let host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let port = source.port.unwrap_or(1521);
|
||||
if db.is_empty() { format!("//{host}:{port}") } else { format!("//{host}:{port}/{db}") }
|
||||
}
|
||||
|
||||
/// The expanded Instant Client directory, `None` when the search path is used.
|
||||
fn client_dir(dir: &str) -> IdeResult<Option<String>> {
|
||||
let dir = dir.trim();
|
||||
if dir.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let expanded: PathBuf = ide_ssh::expand(dir);
|
||||
if !expanded.is_dir() {
|
||||
return Err(IdeError::invalid(format!("Oracle Instant Client directory not found: {}", expanded.display())));
|
||||
}
|
||||
Ok(Some(expanded.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
/// Loads the Oracle client once per process; the first data source's directory wins.
|
||||
/// Failures are not cached: a corrected directory works without restarting.
|
||||
fn init_client(dir: &str) -> IdeResult<()> {
|
||||
static LOADED: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
|
||||
let dir = dir.trim();
|
||||
let mut loaded = LOADED.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(used) = loaded.as_deref() {
|
||||
if !dir.is_empty() && !used.is_empty() && used != dir {
|
||||
tracing::warn!("Oracle client already loaded from {used}; ignoring {dir}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let mut params = oracle::InitParams::new();
|
||||
if let Some(lib_dir) = client_dir(dir)? {
|
||||
params
|
||||
.oracle_client_lib_dir(lib_dir)
|
||||
.map_err(|e| IdeError::invalid(format!("Oracle Instant Client directory: {e}")))?;
|
||||
}
|
||||
params.init().map_err(|e| {
|
||||
let hint = if e.to_string().contains("DPI-1047") {
|
||||
format!("{e} — set the Instant Client directory (Options tab) or ORACLE_HOME")
|
||||
} else {
|
||||
e.to_string()
|
||||
};
|
||||
IdeError::new(ErrorKind::Io, format!("Oracle client: {hint}"))
|
||||
})?;
|
||||
*loaded = Some(dir.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `rust-oracle` errors are not `Sync`; the pool wants a shareable error.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OracleError(Arc<oracle::Error>);
|
||||
|
||||
impl OracleError {
|
||||
fn new(e: oracle::Error) -> Self {
|
||||
Self(Arc::new(e))
|
||||
}
|
||||
|
||||
fn message(msg: impl Into<String>) -> Self {
|
||||
Self::new(oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.into()))
|
||||
}
|
||||
|
||||
fn into_ide(self) -> IdeError {
|
||||
oracle_err(clone_db(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Database errors (the interesting ones) rebuild cheaply for cloning.
|
||||
fn clone_db(e: &oracle::Error) -> oracle::Error {
|
||||
match e.db_error() {
|
||||
Some(db) => oracle::Error::new(oracle::ErrorKind::OciError, db.message().to_owned()),
|
||||
None => message_error(&e.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn message_error(msg: &str) -> oracle::Error {
|
||||
oracle::Error::new(oracle::ErrorKind::InvalidArgument, msg.to_owned())
|
||||
}
|
||||
|
||||
/// Wrong or missing password: ORA-01017 / ORA-01005; unreachable server: ORA-12xxx / ORA-31xx.
|
||||
pub(crate) fn oracle_err(e: oracle::Error) -> IdeError {
|
||||
let Some(db) = e.db_error() else {
|
||||
return IdeError::new(ErrorKind::InvalidInput, e.to_string());
|
||||
};
|
||||
let msg = db.message().to_owned();
|
||||
if msg.contains("DPI-1047") {
|
||||
return IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("{msg} — set the Instant Client directory (Options tab) or ORACLE_HOME"),
|
||||
);
|
||||
}
|
||||
let code = db.code();
|
||||
let full = format!("[ORA-{code:05}] {msg}");
|
||||
match code {
|
||||
1005 | 1017 | 28000 | 28001 => IdeError::new(ErrorKind::PermissionDenied, full),
|
||||
12150..=12199 | 12537 | 12540 | 12541 | 12543 | 12545 | 3113 | 3114 => IdeError::new(ErrorKind::Io, full),
|
||||
_ => IdeError::new(ErrorKind::InvalidInput, full),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct OraclePool {
|
||||
state: Arc<State>,
|
||||
pool: Arc<bb8::Pool<OracleConnectionManager>>,
|
||||
}
|
||||
|
||||
impl OraclePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: &Setup) -> IdeResult<Self> {
|
||||
init_client(&source.options.instant_client)?;
|
||||
let state = Arc::new(State::of(source, password, setup)?);
|
||||
let pool = bb8::Pool::builder()
|
||||
.max_size(4)
|
||||
.connection_timeout(state.timeout)
|
||||
.build(OracleConnectionManager(state.clone()))
|
||||
.await
|
||||
.map_err(|e| e.into_ide())?;
|
||||
// bb8 connects lazily and reports failed attempts as a bare timeout, which
|
||||
// would turn a wrong password into "timed out connecting to the database".
|
||||
let opened = tokio::task::spawn_blocking({
|
||||
let state = state.clone();
|
||||
move || open(&state)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(format!("Oracle connect task: {e}")))?
|
||||
.map_err(oracle_err)?;
|
||||
let _ = pool.add(OracleHandle { conn: Some(opened) });
|
||||
Ok(Self { state, pool: Arc::new(pool) })
|
||||
}
|
||||
|
||||
/// A connection taken out of the pool for one statement or a held session.
|
||||
pub async fn get(&self) -> IdeResult<OracleConnection> {
|
||||
let pooled = self.pool.get_owned().await.map_err(|e| pool_err(e).into_ide())?;
|
||||
Ok(OracleConnection { state: self.state.clone(), pooled })
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
let state = self.pool.state();
|
||||
state.connections == 0 && state.idle_connections == 0
|
||||
}
|
||||
}
|
||||
|
||||
fn pool_err(e: bb8::RunError<OracleError>) -> OracleError {
|
||||
match e {
|
||||
bb8::RunError::User(e) => e,
|
||||
bb8::RunError::TimedOut => OracleError::message("timed out connecting to the database"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A pooled connection's payload; `conn` is `None` once the connection is
|
||||
/// discarded (a cancelled statement leaves it in the blocking task).
|
||||
pub struct OracleHandle {
|
||||
conn: Option<oracle::Connection>,
|
||||
}
|
||||
|
||||
pub struct OracleConnectionManager(Arc<State>);
|
||||
|
||||
impl ManageConnection for OracleConnectionManager {
|
||||
type Connection = OracleHandle;
|
||||
type Error = OracleError;
|
||||
|
||||
async fn connect(&self) -> Result<Self::Connection, Self::Error> {
|
||||
let state = self.0.clone();
|
||||
let opened = tokio::task::spawn_blocking(move || open(&state))
|
||||
.await
|
||||
.map_err(|e| OracleError::message(format!("Oracle connect task: {e}")))?;
|
||||
let conn = opened.map_err(OracleError::new)?;
|
||||
Ok(OracleHandle { conn: Some(conn) })
|
||||
}
|
||||
|
||||
async fn is_valid(&self, conn: &mut Self::Connection) -> Result<(), Self::Error> {
|
||||
let state = self.0.clone();
|
||||
let Some(raw) = conn.conn.take() else {
|
||||
return Err(OracleError::message("Oracle connection is closed"));
|
||||
};
|
||||
let ping = tokio::time::timeout(
|
||||
state.timeout,
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let result = (|| -> oracle::Result<()> {
|
||||
// `execute` rejects selects, so read one row.
|
||||
let mut stmt = raw.statement("SELECT 1 FROM DUAL").build()?;
|
||||
let mut rows = stmt.query(&[])?;
|
||||
if let Some(row) = rows.next() {
|
||||
row?;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
(raw, result)
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
match ping {
|
||||
Ok(Ok((raw, Ok(())))) => {
|
||||
conn.conn = Some(raw);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Ok((_, Err(e)))) => Err(OracleError::new(e)),
|
||||
Ok(Err(e)) => Err(OracleError::message(format!("Oracle ping task: {e}"))),
|
||||
// Timed out (the task keeps the connection and closes it): discard this one.
|
||||
Err(_) => Err(OracleError::message("timed out waiting for the Oracle server")),
|
||||
}
|
||||
}
|
||||
|
||||
fn has_broken(&self, conn: &mut Self::Connection) -> bool {
|
||||
conn.conn.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection borrowed from the pool. It returns to the pool when dropped,
|
||||
/// unless its statement was cancelled (then it is discarded).
|
||||
pub struct OracleConnection {
|
||||
state: Arc<State>,
|
||||
pooled: bb8::PooledConnection<'static, OracleConnectionManager>,
|
||||
}
|
||||
|
||||
impl OracleConnection {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.pooled.conn.is_none()
|
||||
}
|
||||
|
||||
/// Drops the connection instead of returning it; the server rolls an open
|
||||
/// transaction back.
|
||||
pub(crate) fn discard_inner(&mut self) {
|
||||
self.pooled.conn.take();
|
||||
}
|
||||
}
|
||||
|
||||
fn open(state: &State) -> oracle::Result<oracle::Connection> {
|
||||
let mut connector = oracle::Connector::new(state.user.clone(), state.password.clone(), state.connect.clone());
|
||||
connector.driver_name("OpenIDEA");
|
||||
if let Some(n) = state.statement_cache {
|
||||
connector.stmt_cache_size(n);
|
||||
}
|
||||
let mut conn = connector.connect()?;
|
||||
conn.set_autocommit(state.auto_commit);
|
||||
for sql in &state.init {
|
||||
statement(&mut conn, sql, 0, 0, Cells::Text).map_err(|e| message_error(&e.message))?;
|
||||
}
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Runs `f` with the connection on the blocking pool. Cancelling drops the
|
||||
/// connection instead of returning it (its statement keeps running server-side).
|
||||
async fn with_conn<T>(
|
||||
c: &mut OracleConnection,
|
||||
cancel: &CancellationToken,
|
||||
f: impl FnOnce(&mut oracle::Connection) -> IdeResult<T> + Send + 'static,
|
||||
) -> IdeResult<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
{
|
||||
let Some(conn) = c.pooled.conn.take() else {
|
||||
return Err(IdeError::new(ErrorKind::Io, "Oracle connection is closed"));
|
||||
};
|
||||
let (tx, mut rx) = mpsc::channel::<(oracle::Connection, IdeResult<T>)>(1);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut slot = Some(conn);
|
||||
let result = match slot.as_mut() {
|
||||
Some(conn) => f(conn),
|
||||
None => unreachable!("connection taken above"),
|
||||
};
|
||||
// A closed receiver (cancelled) drops the connection, closing it.
|
||||
let _ = tx.blocking_send((slot.take().expect("some"), result));
|
||||
});
|
||||
tokio::select! {
|
||||
item = rx.recv() => match item {
|
||||
Some((conn, result)) => {
|
||||
c.pooled.conn = Some(conn);
|
||||
result
|
||||
}
|
||||
None => Err(IdeError::internal("the Oracle connection task failed")),
|
||||
},
|
||||
() = cancel.cancelled() => {
|
||||
rx.close();
|
||||
Err(IdeError::cancelled())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a script on `c` (see [`crate::conn::Pool::rows`] and `Held::execute`).
|
||||
pub(crate) async fn run(
|
||||
c: &mut OracleConnection,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max: usize,
|
||||
mode: Cells,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Fetched> {
|
||||
let state = c.state.clone();
|
||||
let sql = sql.to_owned();
|
||||
with_conn(c, cancel, move |conn| statements(conn, &state, &sql, skip, max, mode)).await
|
||||
}
|
||||
|
||||
/// The table editor's edits in one transaction.
|
||||
pub(crate) async fn transaction(c: &mut OracleConnection, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
if c.state.read_only {
|
||||
return Err(IdeError::new(ErrorKind::PermissionDenied, "read-only connection: edits are not allowed"));
|
||||
}
|
||||
let script = statements.iter().map(|(sql, _)| sql.clone()).collect::<Vec<_>>();
|
||||
let expect: Vec<bool> = statements.iter().map(|(_, e)| *e).collect();
|
||||
let apply = move |conn: &mut oracle::Connection| -> IdeResult<u64> {
|
||||
conn.set_autocommit(false);
|
||||
let mut total = 0;
|
||||
for (sql, expect_one) in script.iter().zip(&expect) {
|
||||
let mut stmt = conn.statement(sql).build().map_err(oracle_err)?;
|
||||
stmt.execute(&[]).map_err(oracle_err)?;
|
||||
let n = stmt.row_count().map_err(oracle_err)?;
|
||||
if *expect_one && n != 1 {
|
||||
conn.rollback().ok();
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{sql}: expected to change 1 row, changed {n}; nothing was saved"
|
||||
)));
|
||||
}
|
||||
total += n;
|
||||
}
|
||||
conn.commit().map_err(oracle_err)?;
|
||||
Ok(total)
|
||||
};
|
||||
let cancel = CancellationToken::new();
|
||||
with_conn(c, &cancel, apply).await
|
||||
}
|
||||
|
||||
/// Runs every statement of `sql` (one script, PL/SQL blocks kept whole): the
|
||||
/// first query's rows are the result, otherwise the total rows changed.
|
||||
fn statements(
|
||||
conn: &mut oracle::Connection,
|
||||
state: &State,
|
||||
sql: &str,
|
||||
skip: usize,
|
||||
max: usize,
|
||||
mode: Cells,
|
||||
) -> IdeResult<Fetched> {
|
||||
conn.set_autocommit(state.auto_commit);
|
||||
let mut query: Option<Fetched> = None;
|
||||
let mut affected: u64 = 0;
|
||||
let mut first: Option<Instant> = None;
|
||||
for s in crate::split::split(sql, DbDriver::Oracle) {
|
||||
if state.read_only && is_write(&s.text) {
|
||||
return Err(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!("read-only connection: {} statements are not allowed", first_word(&s.text)),
|
||||
));
|
||||
}
|
||||
let r = statement(conn, &s.text, skip, max, mode)?;
|
||||
first.get_or_insert_with(|| r.4.unwrap_or_else(Instant::now));
|
||||
if r.0.is_empty() {
|
||||
affected += r.2.unwrap_or(0);
|
||||
} else if query.is_none() {
|
||||
query = Some(r);
|
||||
}
|
||||
}
|
||||
Ok(query.unwrap_or((Vec::new(), Vec::new(), Some(affected), false, first)))
|
||||
}
|
||||
|
||||
/// Reads one statement; `skip` rows are fetched and dropped (result pagination).
|
||||
fn statement(conn: &mut oracle::Connection, sql: &str, skip: usize, max: usize, mode: Cells) -> IdeResult<Fetched> {
|
||||
let started = Instant::now();
|
||||
// LOB locators let `cell` read only the shown prefix (the default fetches whole LOBs).
|
||||
let mut stmt = conn
|
||||
.statement(sql)
|
||||
.fetch_array_size((max as u32).clamp(1, 1000) + 1)
|
||||
.lob_locator()
|
||||
.build()
|
||||
.map_err(oracle_err)?;
|
||||
match stmt.statement_type() {
|
||||
oracle::StatementType::Select => {
|
||||
let rows = stmt.query(&[]).map_err(oracle_err)?;
|
||||
let columns: Vec<ResultColumn> = rows
|
||||
.column_info()
|
||||
.iter()
|
||||
.map(|c| ResultColumn { name: c.name().to_owned(), type_name: c.oracle_type().to_string() })
|
||||
.collect();
|
||||
let mut out: Vec<Vec<Option<String>>> = Vec::new();
|
||||
let mut has_more = false;
|
||||
let mut to_skip = skip;
|
||||
for row in rows {
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if out.len() >= max {
|
||||
has_more = true;
|
||||
break;
|
||||
}
|
||||
let row = row.map_err(oracle_err)?;
|
||||
out.push(row.sql_values().iter().map(|v| cell(v, mode)).collect());
|
||||
}
|
||||
Ok((columns, out, None, has_more, Some(started)))
|
||||
}
|
||||
_ => {
|
||||
stmt.execute(&[]).map_err(oracle_err)?;
|
||||
let affected = stmt.row_count().map_err(oracle_err)?;
|
||||
Ok((Vec::new(), Vec::new(), Some(affected), false, Some(started)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn first_word(sql: &str) -> String {
|
||||
sql.lines()
|
||||
.map(str::trim)
|
||||
.find(|l| !l.is_empty() && !l.starts_with("--"))
|
||||
.and_then(|l| l.split_whitespace().next())
|
||||
.unwrap_or("")
|
||||
.to_ascii_uppercase()
|
||||
}
|
||||
|
||||
/// Whether `sql` changes data or schema (read-only connections refuse it).
|
||||
fn is_write(sql: &str) -> bool {
|
||||
!matches!(first_word(sql).as_str(), "SELECT" | "WITH" | "SHOW" | "EXPLAIN" | "DESCRIBE" | "DESC" | "SET")
|
||||
}
|
||||
|
||||
fn cell(v: &oracle::SqlValue, mode: Cells) -> Option<String> {
|
||||
if v.is_null().unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
match v.oracle_type().ok() {
|
||||
Some(OracleType::CLOB | OracleType::NCLOB) => {
|
||||
let lob: Clob = v.get().ok()?;
|
||||
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
|
||||
let mut s = String::new();
|
||||
lob.take(cap).read_to_string(&mut s).ok()?;
|
||||
Some(s)
|
||||
}
|
||||
Some(OracleType::BLOB) => {
|
||||
let lob: Blob = v.get().ok()?;
|
||||
let cap = if mode == Cells::Text { MAX_TEXT_LOB } else { MAX_GRID_LOB };
|
||||
let mut buf = Vec::new();
|
||||
lob.take(cap).read_to_end(&mut buf).ok()?;
|
||||
Some(hex(&buf))
|
||||
}
|
||||
_ => v.get::<String>().ok(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn connect_strings() {
|
||||
let s = |db: &str| DataSource {
|
||||
driver: DbDriver::Oracle,
|
||||
host: "db.local".into(),
|
||||
port: Some(1521),
|
||||
database: db.into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(connect_string(&s("ORCLPDB1")), "//db.local:1521/ORCLPDB1");
|
||||
assert_eq!(connect_string(&s("")), "//db.local:1521");
|
||||
assert_eq!(
|
||||
connect_string(&DataSource { host: String::new(), port: None, database: "ORCL".into(), ..s("") }),
|
||||
"//localhost:1521/ORCL"
|
||||
);
|
||||
assert_eq!(connect_string(&s("(DESCRIPTION=(ADDRESS=...))")), "(DESCRIPTION=(ADDRESS=...))");
|
||||
assert_eq!(connect_string(&s("PROD=MYDB")), "PROD=MYDB");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_detection() {
|
||||
assert!(!is_write("SELECT 1 FROM DUAL"));
|
||||
assert!(!is_write("-- x\nWITH q AS (SELECT 1) SELECT * FROM q"));
|
||||
assert!(!is_write("DESC users"));
|
||||
assert!(is_write("INSERT INTO t VALUES (1)"));
|
||||
assert!(is_write("begin\n null;\nend;"));
|
||||
assert!(is_write("DROP TABLE t"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_user_is_rejected_before_connecting() {
|
||||
let setup = Setup::default();
|
||||
let err = State::of(&DataSource { driver: DbDriver::Oracle, ..Default::default() }, None, &setup).unwrap_err();
|
||||
assert!(err.message.contains("user name"));
|
||||
let state = State::of(
|
||||
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
|
||||
Some("tiger"),
|
||||
&setup,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(state.user.as_str(), state.password.as_str(), state.connect.as_str()),
|
||||
("scott", "tiger", "//localhost:1521")
|
||||
);
|
||||
assert!(state.auto_commit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_errors_map_to_kinds() {
|
||||
let auth = oracle_err(message_error("x")).kind; // no database error: invalid input
|
||||
assert_eq!(auth, ErrorKind::InvalidInput);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_discarded_connection_is_broken_but_a_live_one_is_not() {
|
||||
// No server is reachable here, so the pooled connection is a husk: `has_broken`
|
||||
// decides whether bb8 keeps it, which is what cancellation relies on.
|
||||
let manager = OracleConnectionManager(Arc::new(
|
||||
State::of(
|
||||
&DataSource { driver: DbDriver::Oracle, user: "scott".into(), ..Default::default() },
|
||||
None,
|
||||
&Setup::default(),
|
||||
)
|
||||
.unwrap(),
|
||||
));
|
||||
let mut broken = OracleHandle { conn: None };
|
||||
assert!(manager.has_broken(&mut broken));
|
||||
// A connection that cannot be opened reports the failure without panicking
|
||||
// (a client-load error, or ORA-12541 when a client is available).
|
||||
match manager.connect().await {
|
||||
Ok(_) => unreachable!("no server is listening"),
|
||||
Err(err) => assert!(!err.into_ide().message.is_empty()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_reports_a_missing_client_directory() {
|
||||
let err = client_dir("/nonexistent/instantclient").expect_err("no such directory");
|
||||
assert!(err.message.contains("Instant Client directory not found"), "{}", err.message);
|
||||
assert!(client_dir("").expect("the search path").is_none());
|
||||
}
|
||||
|
||||
/// A live server, skipped unless `OPENIDEA_ORACLE_TEST` is
|
||||
/// `user/password@host:port/service` (and `OPENIDEA_ORACLE_CLIENT` names the
|
||||
/// Instant Client when it is not on the search path).
|
||||
///
|
||||
/// Checking out a connection runs `is_valid`, so this fails if the ping is
|
||||
/// not a query (bb8 silently retries a rejected check-out until it times out).
|
||||
#[tokio::test]
|
||||
async fn a_pooled_connection_runs_queries() {
|
||||
let Some(spec) = std::env::var("OPENIDEA_ORACLE_TEST").ok().filter(|s| !s.is_empty()) else { return };
|
||||
let (auth, addr) = spec.split_once('@').expect("user/password@host:port/service");
|
||||
let (user, password) = auth.split_once('/').expect("user/password");
|
||||
let (host, rest) = addr.split_once(':').expect("host:port/service");
|
||||
let (port, service) = rest.split_once('/').expect("port/service");
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Oracle,
|
||||
host: host.into(),
|
||||
port: Some(port.parse().expect("port")),
|
||||
database: service.into(),
|
||||
user: user.into(),
|
||||
options: crate::source::DbOptions {
|
||||
instant_client: std::env::var("OPENIDEA_ORACLE_CLIENT").unwrap_or_default(),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
// A missing or wrong password asks for one (the UI prompts on PermissionDenied).
|
||||
for wrong in [None, Some("wrong")] {
|
||||
let Err(err) = crate::conn::Pool::connect(&source, wrong, None).await else { panic!("bad password") };
|
||||
assert_eq!(err.kind, ErrorKind::PermissionDenied, "{}", err.message);
|
||||
}
|
||||
let pool = crate::conn::Pool::connect(&source, Some(password), None).await.expect("connect");
|
||||
let rows = pool.rows("SELECT 1 FROM DUAL", 1).await.expect("query through the pool");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("1"));
|
||||
|
||||
// The result grid reads later pages by skipping rows client-side.
|
||||
let page = pool
|
||||
.execute_from("SELECT LEVEL FROM DUAL CONNECT BY LEVEL <= 10", 4, 3, &CancellationToken::new())
|
||||
.await
|
||||
.expect("second page");
|
||||
let values: Vec<String> = page.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect();
|
||||
assert_eq!(values, ["5", "6", "7"]);
|
||||
assert!(page.has_more);
|
||||
|
||||
// PL/SQL units reach the server with their closing `END;`.
|
||||
pool.rows("begin null; end;", 0).await.expect("anonymous block");
|
||||
pool.rows("declare n number := 1; begin n := n + 1; end;", 0).await.expect("declare block");
|
||||
let proc = "create or replace procedure openidea_p(a number) is\n x number := a;\nbegin\n x := x + 1;\nend;";
|
||||
let rows = pool.rows(&format!("{proc}\nselect 7 from dual;"), 1).await.expect("procedure, then a query");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("7"));
|
||||
pool.rows("begin openidea_p(1); end;", 0).await.expect("call the procedure");
|
||||
let package = "create or replace package openidea_k as\n function f return number;\nend openidea_k;\n/\n\
|
||||
create or replace package body openidea_k as\n function f return number is begin return 8; end;\nend openidea_k;\n/";
|
||||
pool.rows(package, 0).await.expect("package spec and body");
|
||||
let rows = pool.rows("select openidea_k.f from dual", 1).await.expect("call the package");
|
||||
assert_eq!(rows.first().and_then(|r| r.first().cloned().flatten()).as_deref(), Some("8"));
|
||||
pool.rows("drop package openidea_k; drop procedure openidea_p", 0).await.expect("clean up");
|
||||
|
||||
// LOB cells are read through locators.
|
||||
let rows = pool.rows("select to_clob('abc'), to_blob(hextoraw('0A0B')) from dual", 1).await.expect("LOBs");
|
||||
assert_eq!(rows.first().cloned(), Some(vec![Some("abc".into()), Some("0x0A0B".into())]));
|
||||
}
|
||||
}
|
||||
@@ -38,28 +38,13 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
DbDriver::Oracle => format!("EXPLAIN PLAN FOR {s}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the plan `EXPLAIN PLAN SET STATEMENT_ID = '{plan_id}'` wrote (see [`parse`] with `DbDriver::Oracle`).
|
||||
pub fn oracle_plan_query(plan_id: &str) -> String {
|
||||
format!(
|
||||
"SELECT id, parent_id, operation, options, object_name, cost, cardinality, bytes \
|
||||
FROM plan_table WHERE statement_id = '{plan_id}' ORDER BY id"
|
||||
)
|
||||
}
|
||||
|
||||
/// Removes a read plan (PLAN_TABLE keeps rows until the session ends).
|
||||
pub fn oracle_plan_cleanup(plan_id: &str) -> String {
|
||||
format!("DELETE FROM plan_table WHERE statement_id = '{plan_id}'")
|
||||
}
|
||||
|
||||
/// Reads the result of [`explain_statement`].
|
||||
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
|
||||
match driver {
|
||||
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
|
||||
DbDriver::Oracle => Ok(oracle(&result.rows)),
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => {
|
||||
let text: String =
|
||||
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
|
||||
@@ -163,40 +148,6 @@ fn postgres(node: &Value, parent: Option<u32>, out: &mut Vec<PlanNode>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle PLAN_TABLE rows: `id, parent_id, operation, options, object_name, cost, cardinality, bytes`.
|
||||
fn oracle(rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
let cell = |i: usize| r.get(i).cloned().flatten().unwrap_or_default();
|
||||
let (id, parent, operation, options, object) = (cell(0), cell(1), cell(2), cell(3), cell(4));
|
||||
let parent = ids.iter().position(|x| *x == parent).map(|i| i as u32);
|
||||
let op = format!("{operation} {options}").trim().to_owned();
|
||||
let (operation, params) = match (operation.as_str(), options.as_str()) {
|
||||
("TABLE ACCESS", "FULL") => ("Full Scan".into(), object),
|
||||
("TABLE ACCESS", "BY INDEX ROWID" | "BY ROWID RANGE" | "BY USER ROWID") => ("Table Access".into(), object),
|
||||
("INDEX", "RANGE SCAN" | "RANGE SCAN DESCENDING") => ("Index Range Scan".into(), object),
|
||||
("INDEX", "UNIQUE SCAN") => ("Index Lookup".into(), object),
|
||||
("INDEX", "FULL SCAN" | "FAST FULL SCAN" | "SKIP SCAN") => ("Full Index Scan".into(), object),
|
||||
("INDEX", "SAMPLE FAST FULL SCAN") => ("Full Index Scan".into(), object),
|
||||
("SORT", "ORDER BY") => ("Order By".into(), String::new()),
|
||||
("SORT", "GROUP BY") => ("Group By".into(), String::new()),
|
||||
("SORT", "UNIQUE") => ("Distinct".into(), String::new()),
|
||||
("HASH", "GROUP BY") => ("Group By".into(), String::new()),
|
||||
_ => (op, object),
|
||||
};
|
||||
let mut properties = Vec::new();
|
||||
for (name, value) in [("Cost", 5), ("Rows", 6), ("Bytes", 7)] {
|
||||
if let Some(v) = r.get(value).cloned().flatten() {
|
||||
properties.push(prop(name, v));
|
||||
}
|
||||
}
|
||||
out.push(PlanNode { parent, operation, params, properties });
|
||||
ids.push(id);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// MySQL's tabular `EXPLAIN`: one row per table access, flat.
|
||||
fn mysql(names: &[&str], rows: &[Vec<Option<String>>]) -> Vec<PlanNode> {
|
||||
let col = |r: &[Option<String>], n: &str| {
|
||||
@@ -251,49 +202,6 @@ mod tests {
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
|
||||
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Oracle, "select 1"), "EXPLAIN PLAN FOR select 1");
|
||||
let q = oracle_plan_query("OI1");
|
||||
assert!(q.contains("FROM plan_table WHERE statement_id = 'OI1' ORDER BY id"));
|
||||
assert!(oracle_plan_cleanup("OI1").contains("DELETE FROM plan_table WHERE statement_id = 'OI1'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_oracle_plan_table_rows() {
|
||||
let r = result(
|
||||
&["id", "parent_id", "operation", "options", "object_name", "cost", "cardinality", "bytes"],
|
||||
vec![
|
||||
vec![Some("1"), Some("0"), Some("SELECT STATEMENT"), None, None, Some("3"), Some("41"), Some("1230")],
|
||||
vec![Some("2"), Some("1"), Some("HASH"), Some("GROUP BY"), None, Some("3"), Some("41"), Some("1230")],
|
||||
vec![
|
||||
Some("4"),
|
||||
Some("2"),
|
||||
Some("TABLE ACCESS"),
|
||||
Some("FULL"),
|
||||
Some("orders"),
|
||||
Some("2"),
|
||||
Some("41"),
|
||||
Some("1230"),
|
||||
],
|
||||
vec![
|
||||
Some("5"),
|
||||
Some("2"),
|
||||
Some("INDEX"),
|
||||
Some("UNIQUE SCAN"),
|
||||
Some("c_pk"),
|
||||
Some("0"),
|
||||
Some("1"),
|
||||
Some("6"),
|
||||
],
|
||||
],
|
||||
);
|
||||
let plan = parse(DbDriver::Oracle, &r).unwrap();
|
||||
let ops: Vec<(&str, &str)> = plan.iter().map(|n| (n.operation.as_str(), n.params.as_str())).collect();
|
||||
assert_eq!(
|
||||
ops,
|
||||
[("SELECT STATEMENT", ""), ("Group By", ""), ("Full Scan", "orders"), ("Index Lookup", "c_pk")]
|
||||
);
|
||||
assert_eq!((plan[2].parent, plan[3].parent), (Some(1), Some(1)));
|
||||
assert!(plan[0].properties.iter().any(|p| p.name == "Cost" && p.value == "3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -20,8 +20,6 @@ pub enum DbDriver {
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
/// Oracle Database (via Oracle Instant Client).
|
||||
Oracle,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -32,7 +30,6 @@ impl DbDriver {
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
DbDriver::Oracle => Some(1521),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -88,8 +85,6 @@ pub struct DbOptions {
|
||||
pub auto_disconnect_secs: Option<u32>,
|
||||
/// PostgreSQL: show only the configured database, its schemas right under the data source.
|
||||
pub single_database: bool,
|
||||
/// Oracle: directory of the Oracle Instant Client (empty: `ORACLE_HOME` / system search path).
|
||||
pub instant_client: String,
|
||||
}
|
||||
|
||||
/// Schemas tab: the databases / schemas the tree shows. Names may use `*` and `?`.
|
||||
@@ -266,7 +261,6 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
"oracle" | "oracledb" => Some(DbDriver::Oracle),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -279,12 +273,6 @@ pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
let driver = scheme_driver(scheme).ok_or_else(bad)?;
|
||||
// JDBC thin URLs: `jdbc:oracle:thin:@//host:port/service` (after the `jdbc:` prefix).
|
||||
let rest = if driver == DbDriver::Oracle {
|
||||
rest.strip_prefix("thin:@").or_else(|| rest.strip_prefix("thin:/@")).unwrap_or(rest)
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
if driver == DbDriver::Sqlite {
|
||||
let file = rest.strip_prefix("//").unwrap_or(rest);
|
||||
let file = file.split('?').next().unwrap_or_default();
|
||||
@@ -552,11 +540,7 @@ mod tests {
|
||||
assert_eq!(parse_url("jdbc:redshift://r:5439/dev").unwrap().driver, Some(DbDriver::Postgres));
|
||||
assert_eq!(parse_url("tidb://h:4000").unwrap().driver, Some(DbDriver::Mysql));
|
||||
assert_eq!(parse_url("sqlite:/tmp/a.db").unwrap().file.as_deref(), Some("/tmp/a.db"));
|
||||
let u = parse_url("oracle://scott:[email protected]:1521/ORCLPDB1").unwrap();
|
||||
assert_eq!(u.driver, Some(DbDriver::Oracle));
|
||||
assert_eq!((u.host.as_str(), u.port, u.database.as_str()), ("db.local", Some(1521), "ORCLPDB1"));
|
||||
assert_eq!((u.user.as_str(), u.password.as_deref()), ("scott", Some("tiger")));
|
||||
assert_eq!(parse_url("oracledb://[email protected]/hrdb").unwrap().driver, Some(DbDriver::Oracle));
|
||||
assert!(parse_url("oracle://x").is_err());
|
||||
assert!(parse_url("postgres://h:99999/db").is_err());
|
||||
}
|
||||
|
||||
|
||||
+7
-197
@@ -9,8 +9,7 @@ use specta::Type;
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SqlStatement {
|
||||
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded except
|
||||
/// after an Oracle PL/SQL block, whose `END;` needs it).
|
||||
/// UTF-16 offsets of the statement text (whitespace trimmed, `;` excluded).
|
||||
pub from: u32,
|
||||
pub to: u32,
|
||||
pub text: String,
|
||||
@@ -30,8 +29,6 @@ enum State {
|
||||
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mysql = driver == DbDriver::Mysql;
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
// PL/SQL: `;` inside BEGIN…END / DECLARE…END blocks does not terminate a statement.
|
||||
let plsql = driver == DbDriver::Oracle;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
// UTF-16 offset of each char index (plus the end).
|
||||
let mut u16_at = Vec::with_capacity(chars.len() + 1);
|
||||
@@ -49,14 +46,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
// Whether the current segment has anything but whitespace / comments.
|
||||
let mut has_code = false;
|
||||
let mut i = 0usize;
|
||||
// PL/SQL block depth (BEGIN / CASE / LOOP open, END closes).
|
||||
let mut depth = 0usize;
|
||||
// Inside a top-level block's declaration section (`DECLARE … BEGIN`).
|
||||
let mut declarations = false;
|
||||
// The segment is a PL/SQL block: its closing `;` is part of the statement.
|
||||
let mut block = false;
|
||||
// `CREATE PACKAGE` / `TYPE BODY`: only `/` on its own line (or the end) ends it.
|
||||
let mut package = false;
|
||||
let push = |out: &mut Vec<SqlStatement>, s: usize, e: usize, has_code: bool| {
|
||||
if !has_code {
|
||||
return;
|
||||
@@ -80,24 +69,11 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let next = chars.get(i + 1).map(|c| c.1);
|
||||
match state {
|
||||
State::Code => match c {
|
||||
_ if plsql && !has_code && c.is_alphabetic() => {
|
||||
match plsql_unit(&chars, i) {
|
||||
Unit::Sql => {}
|
||||
Unit::Block => block = true,
|
||||
Unit::Subprogram => (block, declarations) = (true, true),
|
||||
Unit::Package => (block, package) = (true, true),
|
||||
}
|
||||
has_code = true;
|
||||
// Re-read this character as code (it may start BEGIN / DECLARE).
|
||||
continue;
|
||||
}
|
||||
';' if depth == 0 && !declarations && !package => {
|
||||
push(&mut out, start, if block { i + 1 } else { i }, has_code);
|
||||
';' => {
|
||||
push(&mut out, start, i, has_code);
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
block = false;
|
||||
}
|
||||
';' => has_code = true,
|
||||
'\'' => {
|
||||
state = State::Single;
|
||||
has_code = true;
|
||||
@@ -110,28 +86,15 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
state = State::Backtick;
|
||||
has_code = true;
|
||||
}
|
||||
'/' if next == Some('*') => {
|
||||
state = State::BlockComment;
|
||||
i += 1;
|
||||
}
|
||||
'/' if plsql && ends_line(&chars, i) && starts_line(&chars, i) => {
|
||||
// SQL*Plus block terminator: `/` alone on its line ends the statement.
|
||||
push(&mut out, start, i, has_code);
|
||||
while chars.get(i).is_some_and(|&(_, c)| c != '\n') {
|
||||
i += 1;
|
||||
}
|
||||
start = i + 1;
|
||||
has_code = false;
|
||||
depth = 0;
|
||||
declarations = false;
|
||||
block = false;
|
||||
package = false;
|
||||
}
|
||||
'-' if next == Some('-') => {
|
||||
state = State::LineComment;
|
||||
i += 1;
|
||||
}
|
||||
'#' if mysql => state = State::LineComment,
|
||||
'/' if next == Some('*') => {
|
||||
state = State::BlockComment;
|
||||
i += 1;
|
||||
}
|
||||
'$' if driver == DbDriver::Postgres => {
|
||||
// `$tag$ … $tag$` (tag may be empty); `$1` parameters are not quotes.
|
||||
let rest = &text[byte_at(i)..];
|
||||
@@ -151,34 +114,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "BEGIN") => {
|
||||
depth += 1;
|
||||
declarations = false;
|
||||
has_code = true;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "CASE") || plsql && word_at(&chars, i, "LOOP") => {
|
||||
depth += 1;
|
||||
has_code = true;
|
||||
}
|
||||
// `DECLARE … BEGIN … END;` is one block: the declarations of a top-level block
|
||||
// end at BEGIN, so their `;` must not split it.
|
||||
_ if plsql && word_at(&chars, i, "DECLARE") => {
|
||||
if depth == 0 {
|
||||
declarations = true;
|
||||
}
|
||||
has_code = true;
|
||||
}
|
||||
_ if plsql && word_at(&chars, i, "END") => {
|
||||
// `END IF` closes an IF (which never opened a block); END LOOP / END CASE
|
||||
// close the block their keyword opened. Other ENDs close a block.
|
||||
let after = skip_ws(&chars, i + 3);
|
||||
let closing = ["LOOP", "CASE", "IF"].into_iter().find(|w| word_at(&chars, after, w));
|
||||
if closing != Some("IF") && depth > 0 {
|
||||
depth -= 1;
|
||||
}
|
||||
i = closing.map_or(i + 3, |w| after + w.len()) - 1;
|
||||
has_code = true;
|
||||
}
|
||||
c if !c.is_whitespace() => has_code = true,
|
||||
_ => {}
|
||||
},
|
||||
@@ -229,73 +164,6 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Unit {
|
||||
Sql,
|
||||
/// `DECLARE` / `BEGIN`: an anonymous block.
|
||||
Block,
|
||||
/// `CREATE PROCEDURE` / `FUNCTION` / `TRIGGER`: declarations up to the body's BEGIN.
|
||||
Subprogram,
|
||||
/// `CREATE PACKAGE [BODY]` / `TYPE BODY`.
|
||||
Package,
|
||||
}
|
||||
|
||||
/// What kind of PL/SQL unit, if any, a statement starting at `i` is.
|
||||
fn plsql_unit(chars: &[(usize, char)], mut i: usize) -> Unit {
|
||||
let mut words: Vec<String> = Vec::new();
|
||||
while words.len() < 6 {
|
||||
let word: String = chars[i.min(chars.len())..]
|
||||
.iter()
|
||||
.map(|&(_, c)| c)
|
||||
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
|
||||
.collect();
|
||||
if word.is_empty() {
|
||||
break;
|
||||
}
|
||||
i = skip_ws(chars, i + word.chars().count());
|
||||
words.push(word.to_ascii_uppercase());
|
||||
}
|
||||
let w = |n: usize| words.get(n).map_or("", String::as_str);
|
||||
match w(0) {
|
||||
"DECLARE" | "BEGIN" => return Unit::Block,
|
||||
"CREATE" => {}
|
||||
_ => return Unit::Sql,
|
||||
}
|
||||
let mut n = if (w(1), w(2)) == ("OR", "REPLACE") { 3 } else { 1 };
|
||||
if matches!(w(n), "EDITIONABLE" | "NONEDITIONABLE") {
|
||||
n += 1;
|
||||
}
|
||||
match (w(n), w(n + 1)) {
|
||||
("PROCEDURE" | "FUNCTION" | "TRIGGER", _) => Unit::Subprogram,
|
||||
("PACKAGE", _) | ("TYPE", "BODY") => Unit::Package,
|
||||
_ => Unit::Sql,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the characters at `i` are the keyword `word` (identifier bounds respected).
|
||||
fn word_at(chars: &[(usize, char)], i: usize, word: &str) -> bool {
|
||||
let (before, after) = (chars[..i].last().map(|&(_, c)| c), chars.get(i).map(|&(_, c)| c));
|
||||
before.is_none_or(|c| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
&& after.is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& chars[i..].iter().take(word.len()).map(|&(_, c)| c.to_ascii_uppercase()).eq(word.chars())
|
||||
&& chars.get(i + word.len()).is_none_or(|&(_, c)| !c.is_alphanumeric() && c != '_' && c != '$')
|
||||
}
|
||||
|
||||
/// Whether only whitespace follows `i` until the end of its line.
|
||||
fn ends_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[i + 1..].iter().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
}
|
||||
|
||||
/// Whether only whitespace precedes `i` since the start of its line.
|
||||
fn starts_line(chars: &[(usize, char)], i: usize) -> bool {
|
||||
chars[..i].iter().rev().take_while(|&&(_, c)| c != '\n').all(|&(_, c)| c.is_whitespace())
|
||||
}
|
||||
|
||||
/// The index of the first non-whitespace character at or after `i`.
|
||||
fn skip_ws(chars: &[(usize, char)], i: usize) -> usize {
|
||||
chars[i..].iter().position(|&(_, c)| !c.is_whitespace()).map_or(chars.len(), |n| i + n)
|
||||
}
|
||||
|
||||
/// The statement to run for a caret at `offset` (UTF-16): the one containing
|
||||
/// it, else the nearest one before it (caret after `;` or on a blank line
|
||||
/// below), else the next one.
|
||||
@@ -409,62 +277,4 @@ mod tests {
|
||||
assert_eq!(s.iter().map(|s| s.text.as_str()).collect::<Vec<_>>(), vec!["select 1", "select 2"]);
|
||||
assert_eq!((s[1].from, s[1].to), (13, 21));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_keeps_plsql_blocks_as_one_statement() {
|
||||
let block = "begin\n for r in (select 1 from dual) loop\n null; -- ;\n end loop;\nend";
|
||||
assert_eq!(
|
||||
texts(&format!("{block};\nselect 2;"), DbDriver::Oracle),
|
||||
vec![format!("{block};"), "select 2".into()]
|
||||
);
|
||||
let nested = "declare\n x number;\nbegin\n if x = 1 then\n case when x = 2 then\n null;\n end case;\n begin\n null;\n end;\n end if;\nend";
|
||||
assert_eq!(
|
||||
texts(&format!("{nested};\nselect 3;"), DbDriver::Oracle),
|
||||
vec![format!("{nested};"), "select 3".into()]
|
||||
);
|
||||
// Plain SQL with CASE is unaffected; END IF does not close a block twice.
|
||||
assert_eq!(
|
||||
texts("select case when a = 1 then 2 end from t; select 2", DbDriver::Oracle),
|
||||
vec!["select case when a = 1 then 2 end from t", "select 2"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_slash_terminator_and_other_drivers_unaffected() {
|
||||
// `END;` ends the block; a following `/` (SQL*Plus's own terminator) adds nothing.
|
||||
assert_eq!(
|
||||
texts("begin\n null;\nend;\n/\nselect 2", DbDriver::Oracle),
|
||||
vec!["begin\n null;\nend;", "select 2"]
|
||||
);
|
||||
// A division is not a terminator.
|
||||
assert_eq!(texts("select 1 /\n 2 from dual", DbDriver::Oracle), vec!["select 1 /\n 2 from dual"]);
|
||||
assert_eq!(texts("select 'a;b' from t; select 2", DbDriver::Oracle), vec!["select 'a;b' from t", "select 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_create_units_keep_their_declarations_and_end() {
|
||||
let proc = "create or replace procedure p(a number) is\n x number := 1;\nbegin\n null;\nend p;";
|
||||
assert_eq!(texts(&format!("{proc}\nselect 1 from dual;"), DbDriver::Oracle), vec![proc, "select 1 from dual"]);
|
||||
let trigger =
|
||||
"CREATE TRIGGER t BEFORE INSERT ON x FOR EACH ROW\nDECLARE n NUMBER;\nBEGIN\n :new.id := 1;\nEND;";
|
||||
assert_eq!(
|
||||
texts(&format!("{trigger}\nselect 2 from dual"), DbDriver::Oracle),
|
||||
vec![trigger, "select 2 from dual"]
|
||||
);
|
||||
// Packages end at `/` (or the end of the script), like in SQL*Plus.
|
||||
let spec = "create package k as\n procedure a;\n function f return number;\nend k;";
|
||||
let body = "create package body k as\n procedure a is begin null; end;\n function f return number is begin return 1; end;\nbegin\n null;\nend k;";
|
||||
assert_eq!(
|
||||
texts(&format!("{spec}\n/\n{body}\n/\nselect 3 from dual;"), DbDriver::Oracle),
|
||||
vec![spec, body, "select 3 from dual"]
|
||||
);
|
||||
// Other CREATE statements and object types stay plain SQL.
|
||||
assert_eq!(
|
||||
texts(
|
||||
"create view v as select 1 x from dual; create type t as object (a number); select 4",
|
||||
DbDriver::Oracle
|
||||
),
|
||||
vec!["create view v as select 1 x from dual", "create type t as object (a number)", "select 4"]
|
||||
);
|
||||
}
|
||||
}
|
||||
+5
-182
@@ -46,23 +46,6 @@ pub fn databases(templates: bool) -> &'static str {
|
||||
/// Databases of managed services that their users cannot use (hidden unless connected to).
|
||||
pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azure_sys", "cloudsqladmin"];
|
||||
|
||||
/// Oracle system accounts hidden unless `show_system` (see the Schemas tab).
|
||||
pub const ORACLE_SYSTEM_SCHEMAS: &[&str] = &[
|
||||
"CTXSYS",
|
||||
"DBSFWUSER",
|
||||
"DVSYS",
|
||||
"GSMADMIN_INTERNAL",
|
||||
"LBACSYS",
|
||||
"MDDATA",
|
||||
"MDSYS",
|
||||
"OLAPSYS",
|
||||
"OUTLN",
|
||||
"SYS",
|
||||
"SYSTEM",
|
||||
"XDB",
|
||||
"XS$NULL",
|
||||
];
|
||||
|
||||
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
|
||||
@@ -76,12 +59,6 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
}
|
||||
DbDriver::Mysql if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::SqlServer if system => "SELECT schema_name FROM information_schema.schemata ORDER BY 1",
|
||||
DbDriver::Oracle if system => "SELECT username FROM all_users ORDER BY username",
|
||||
DbDriver::Oracle => {
|
||||
"SELECT username FROM all_users WHERE username NOT IN \
|
||||
('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', \
|
||||
'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') ORDER BY username"
|
||||
}
|
||||
DbDriver::Postgres => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema') \
|
||||
@@ -112,12 +89,6 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT table_name, 'table' FROM all_tables WHERE owner = {} \
|
||||
UNION ALL SELECT view_name, 'view' FROM all_views WHERE owner = {} ORDER BY 1",
|
||||
quote_literal(driver, schema),
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Postgres => format!(
|
||||
"SELECT c.relname, CASE c.relkind WHEN 'v' THEN 'view' WHEN 'm' THEN 'materialized view' ELSE 'table' END \
|
||||
FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace \
|
||||
@@ -177,17 +148,6 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
FROM information_schema.columns c WHERE c.table_schema = {s} AND c.table_name = {t} \
|
||||
ORDER BY c.ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), c.data_default, \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM all_tab_columns c \
|
||||
LEFT JOIN (SELECT kc.column_name FROM all_constraints con \
|
||||
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
|
||||
AND kc.constraint_name = con.constraint_name \
|
||||
WHERE con.constraint_type = 'P' AND con.owner = {s} AND con.table_name = {t}) k \
|
||||
ON k.column_name = c.column_name \
|
||||
WHERE c.owner = {s} AND c.table_name = {t} ORDER BY c.column_id"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,12 +185,6 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
WHERE OBJECT_SCHEMA_NAME(i.object_id) = {s} AND OBJECT_NAME(i.object_id) = {t} AND i.name IS NOT NULL \
|
||||
ORDER BY i.name, ic.key_ordinal"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT i.index_name, CASE WHEN i.uniqueness = 'UNIQUE' THEN 1 ELSE 0 END, ic.column_name \
|
||||
FROM all_indexes i JOIN all_ind_columns ic \
|
||||
ON ic.index_owner = i.owner AND ic.index_name = i.index_name \
|
||||
WHERE i.owner = {s} AND i.table_name = {t} ORDER BY i.index_name, ic.column_position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -291,21 +245,6 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
WHERE c.table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY c.table_schema, c.table_name, c.ordinal_position"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT c.owner, c.table_name, CASE WHEN v.view_name IS NOT NULL THEN 'view' ELSE 'table' END, \
|
||||
c.column_name, c.data_type, DECODE(c.nullable, 'Y', 'YES', 'NO'), \
|
||||
CASE WHEN k.column_name IS NOT NULL THEN 1 ELSE 0 END, \
|
||||
CASE WHEN c.data_default IS NOT NULL THEN 1 ELSE 0 END \
|
||||
FROM all_tab_columns c \
|
||||
LEFT JOIN all_views v ON v.owner = c.owner AND v.view_name = c.table_name \
|
||||
LEFT JOIN (SELECT con.owner, con.table_name, kc.column_name FROM all_constraints con \
|
||||
JOIN all_cons_columns kc ON kc.owner = con.owner AND kc.table_name = con.table_name \
|
||||
AND kc.constraint_name = con.constraint_name WHERE con.constraint_type = 'P') k \
|
||||
ON k.owner = c.owner AND k.table_name = c.table_name AND k.column_name = c.column_name \
|
||||
WHERE c.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', 'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', \
|
||||
'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
|
||||
ORDER BY c.owner, c.table_name, c.column_id FETCH FIRST 50000 ROWS ONLY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,19 +284,6 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
AND table_schema NOT IN ('sys', 'information_schema') \
|
||||
ORDER BY table_schema, table_name, constraint_name, ordinal_position"
|
||||
}
|
||||
DbDriver::Oracle => {
|
||||
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, \
|
||||
rcon.owner, rcon.table_name, rcol.column_name \
|
||||
FROM all_constraints con \
|
||||
JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name \
|
||||
AND col.constraint_name = con.constraint_name \
|
||||
JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name \
|
||||
JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name \
|
||||
AND rcol.constraint_name = rcon.constraint_name \
|
||||
WHERE con.constraint_type = 'R' AND con.owner NOT IN ('CTXSYS', 'DBSFWUSER', 'DVSYS', \
|
||||
'GSMADMIN_INTERNAL', 'LBACSYS', 'MDDATA', 'MDSYS', 'OLAPSYS', 'OUTLN', 'SYS', 'SYSTEM', 'XDB', 'XS$NULL') \
|
||||
ORDER BY 1, 2, 3, col.position FETCH FIRST 20000 ROWS ONLY"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,9 +304,6 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
|
||||
DbDriver::SqlServer => format!(
|
||||
"SELECT table_schema, table_name, constraint_name, column_name, referenced_table_schema, referenced_table_name, referenced_column_name FROM information_schema.key_column_usage WHERE referenced_table_name IS NOT NULL AND table_schema = {s} AND table_name = {t} ORDER BY constraint_name, ordinal_position"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT con.owner, con.table_name, con.constraint_name, col.column_name, rcon.owner, rcon.table_name, rcol.column_name FROM all_constraints con JOIN all_cons_columns col ON col.owner = con.owner AND col.table_name = con.table_name AND col.constraint_name = con.constraint_name JOIN all_constraints rcon ON rcon.owner = con.r_owner AND rcon.constraint_name = con.r_constraint_name JOIN all_cons_columns rcol ON rcol.owner = rcon.owner AND rcol.table_name = rcon.table_name AND rcol.constraint_name = rcon.constraint_name WHERE con.constraint_type = 'R' AND con.owner = {s} AND con.table_name = {t} ORDER BY con.constraint_name, col.position"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -392,7 +315,6 @@ pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
DbDriver::Postgres => Some("SELECT current_schema()"),
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
DbDriver::SqlServer => Some("SELECT DB_NAME()"),
|
||||
DbDriver::Oracle => Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,8 +359,6 @@ pub fn table_page(
|
||||
sql.push_str(" ORDER BY (SELECT 1)");
|
||||
}
|
||||
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH NEXT {} ROWS ONLY", limit + 1));
|
||||
} else if driver == DbDriver::Oracle {
|
||||
sql.push_str(&format!(" OFFSET {offset} ROWS FETCH FIRST {} ROWS ONLY", limit + 1));
|
||||
} else {
|
||||
sql.push_str(&format!(" LIMIT {} OFFSET {offset}", limit + 1));
|
||||
}
|
||||
@@ -501,8 +421,6 @@ pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowE
|
||||
if values.is_empty() {
|
||||
return Ok(match driver {
|
||||
DbDriver::Mysql => format!("INSERT INTO {target} () VALUES ()"),
|
||||
// Oracle has no DEFAULT VALUES; without columns there is nothing to insert.
|
||||
DbDriver::Oracle => return Err("Oracle needs at least one column value to insert".into()),
|
||||
_ => format!("INSERT INTO {target} DEFAULT VALUES"),
|
||||
});
|
||||
}
|
||||
@@ -591,12 +509,6 @@ pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
"SELECT t.name, lower(te.type_desc), '' FROM sys.triggers t \
|
||||
WHERE OBJECT_SCHEMA_NAME(t.parent_id) = {s} AND OBJECT_NAME(t.parent_id) = {t} ORDER BY 1"
|
||||
),
|
||||
DbDriver::Oracle => format!(
|
||||
"SELECT trigger_name, CASE WHEN trigger_type LIKE 'BEFORE%' THEN 'before' \
|
||||
WHEN trigger_type LIKE 'INSTEAD%' THEN 'instead of' ELSE 'after' END, \
|
||||
lower(replace(triggering_event, ' OR ', ',')) \
|
||||
FROM all_triggers WHERE owner = {s} AND table_name = {t} ORDER BY 1"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,28 +551,17 @@ pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
"SELECT r.routine_name, lower(r.routine_type), '', r.data_type \
|
||||
FROM information_schema.routines r WHERE r.routine_schema = {s} ORDER BY 1"
|
||||
)),
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT object_name, CASE object_type WHEN 'PROCEDURE' THEN 'procedure' ELSE 'function' END, '', NULL \
|
||||
FROM all_objects WHERE owner = {s} AND object_type IN ('PROCEDURE', 'FUNCTION') \
|
||||
AND object_name NOT LIKE 'BIN$%' ORDER BY 1"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL, Oracle).
|
||||
/// Rows: `name, data type` of a schema's sequences (PostgreSQL).
|
||||
pub fn sequences(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
match driver {
|
||||
DbDriver::Postgres => Some(format!(
|
||||
(driver == DbDriver::Postgres).then(|| {
|
||||
format!(
|
||||
"SELECT sequence_name, data_type FROM information_schema.sequences WHERE sequence_schema = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
// Every sequence is numeric; ALL_SEQUENCES has no data_type column before 18c.
|
||||
DbDriver::Oracle => Some(format!(
|
||||
"SELECT sequence_name, 'NUMBER' FROM all_sequences WHERE sequence_owner = {} ORDER BY 1",
|
||||
quote_literal(driver, schema)
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// The statement reading the definition of a routine (a row per overload), trigger
|
||||
@@ -705,26 +606,6 @@ pub fn object_ddl(driver: DbDriver, schema: &str, kind: crate::DbObjectKind, nam
|
||||
(DbDriver::Mysql, K::Function) => (format!("SHOW CREATE FUNCTION {target}"), 2),
|
||||
(DbDriver::Mysql, K::Procedure) => (format!("SHOW CREATE PROCEDURE {target}"), 2),
|
||||
(DbDriver::Mysql, K::Trigger) => (format!("SHOW CREATE TRIGGER {target}"), 2),
|
||||
(DbDriver::Oracle, K::Function | K::Procedure | K::Trigger) => (
|
||||
format!(
|
||||
"SELECT DBMS_METADATA.GET_DDL('{}', {n}, {s}) FROM DUAL",
|
||||
match kind {
|
||||
K::Function => "FUNCTION",
|
||||
K::Procedure => "PROCEDURE",
|
||||
_ => "TRIGGER",
|
||||
}
|
||||
),
|
||||
0,
|
||||
),
|
||||
(DbDriver::Oracle, K::Sequence) => (
|
||||
format!(
|
||||
"SELECT 'create sequence ' || {n} || E'\\n increment by ' || increment_by || \
|
||||
E'\\n minvalue ' || min_value || E'\\n maxvalue ' || max_value || \
|
||||
E'\\n start with ' || last_number || CASE WHEN cycle_flag = 'Y' THEN E'\\n cycle' ELSE '' END \
|
||||
FROM all_sequences WHERE sequence_owner = {s} AND sequence_name = {n}"
|
||||
),
|
||||
0,
|
||||
),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -911,64 +792,6 @@ mod tests {
|
||||
assert!(completion_columns(DbDriver::SqlServer).contains("information_schema.columns"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_quoting_pages_and_current_schema() {
|
||||
assert_eq!(quote_ident(DbDriver::Oracle, "a\"b"), "\"a\"\"b\"");
|
||||
assert_eq!(quote_literal(DbDriver::Oracle, "it's"), "'it''s'");
|
||||
assert_eq!(qualified(DbDriver::Oracle, "hr", "employees"), "\"hr\".\"employees\"");
|
||||
assert_eq!(current_schema(DbDriver::Oracle), Some("SELECT SYS_CONTEXT('USERENV', 'CURRENT_SCHEMA') FROM DUAL"));
|
||||
assert_eq!(crate::conn::begin_statement(DbDriver::Oracle), "");
|
||||
let sort = [SortKey { column: "id".into(), descending: true }];
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Oracle, "hr", "employees", Some("WHERE id > 3"), &sort, 50, 25),
|
||||
"SELECT * FROM \"hr\".\"employees\" WHERE id > 3 ORDER BY \"id\" DESC OFFSET 50 ROWS FETCH FIRST 26 ROWS ONLY"
|
||||
);
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Oracle, "", "t", None, &[], 0, 10),
|
||||
"SELECT * FROM \"t\" OFFSET 0 ROWS FETCH FIRST 11 ROWS ONLY"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_introspection_queries() {
|
||||
assert_eq!(DbDriver::Oracle.default_port(), Some(1521));
|
||||
assert!(schemas(DbDriver::Oracle, true).contains("all_users"));
|
||||
assert!(schemas(DbDriver::Oracle, false).contains("'MDSYS'"));
|
||||
assert!(tables(DbDriver::Oracle, "hr").contains("all_tables"));
|
||||
assert!(tables(DbDriver::Oracle, "hr").contains("all_views"));
|
||||
let cols = columns(DbDriver::Oracle, "hr", "employees");
|
||||
assert!(cols.contains("all_tab_columns"));
|
||||
assert!(cols.contains("DECODE(c.nullable, 'Y', 'YES', 'NO')"));
|
||||
assert!(cols.contains("constraint_type = 'P'"));
|
||||
assert!(indexes(DbDriver::Oracle, "hr", "t").contains("all_indexes"));
|
||||
assert!(foreign_keys(DbDriver::Oracle).contains("constraint_type = 'R'"));
|
||||
assert!(table_foreign_keys(DbDriver::Oracle, "hr", "t").contains("all_cons_columns"));
|
||||
assert!(completion_columns(DbDriver::Oracle).contains("all_tab_columns"));
|
||||
assert!(triggers(DbDriver::Oracle, "hr", "t").contains("all_triggers"));
|
||||
assert!(routines(DbDriver::Oracle, "hr").unwrap().contains("all_objects"));
|
||||
assert!(sequences(DbDriver::Oracle, "hr").unwrap().contains("all_sequences"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oracle_edits_and_object_ddl() {
|
||||
let key = vec![cv("id", Some("1"))];
|
||||
assert_eq!(
|
||||
edit_statement(
|
||||
DbDriver::Oracle,
|
||||
"hr",
|
||||
"t",
|
||||
&DbRowEdit::Update { key: key.clone(), values: vec![cv("n", Some("x"))] }
|
||||
)
|
||||
.unwrap(),
|
||||
"UPDATE \"hr\".\"t\" SET \"n\" = 'x' WHERE \"id\" = '1'"
|
||||
);
|
||||
assert!(edit_statement(DbDriver::Oracle, "hr", "t", &DbRowEdit::Insert { values: vec![] }).is_err());
|
||||
use crate::DbObjectKind as K;
|
||||
let (q, col) = object_ddl(DbDriver::Oracle, "hr", K::Procedure, "p").unwrap();
|
||||
assert!(q.contains("DBMS_METADATA.GET_DDL('PROCEDURE'") && col == 0);
|
||||
assert!(object_ddl(DbDriver::Oracle, "hr", K::Sequence, "q").unwrap().0.contains("all_sequences"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_trigger_events() {
|
||||
let ev = sqlite_trigger_event;
|
||||
|
||||
@@ -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.2.0";
|
||||
pub const ENGINE_VERSION: &str = "1.1.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.
|
||||
|
||||
@@ -223,10 +223,6 @@ impl Host {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window)?;
|
||||
if let Some(plugins) = plugins::editor_plugin_host() {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), None);
|
||||
plugins.set_active_file(window, None);
|
||||
}
|
||||
if !project.light {
|
||||
self.environment.project_closed();
|
||||
}
|
||||
|
||||
@@ -204,17 +204,6 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
if kind == GotoKind::Definition
|
||||
&& let Some(plugins) = crate::plugins::editor_plugin_host()
|
||||
{
|
||||
let project = host.project(window)?;
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
if let Some(found) = plugins.navigation_at(&project.root, &path, version, pos, Some(window), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}) {
|
||||
return Ok(found.targets);
|
||||
}
|
||||
}
|
||||
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
|
||||
if kind != GotoKind::Definition {
|
||||
return result;
|
||||
|
||||
@@ -103,20 +103,12 @@ pub enum RemoteOp {
|
||||
Pull {
|
||||
rebase: bool,
|
||||
},
|
||||
/// Update Project › Reset to the Remote Branch.
|
||||
ResetToUpstream,
|
||||
Push {
|
||||
force_with_lease: bool,
|
||||
set_upstream: bool,
|
||||
/// Push dialog › Push tags.
|
||||
#[serde(default)]
|
||||
tags: Option<PushTags>,
|
||||
/// Plain `--force` (Force push anyway).
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
/// Push dialog › Run Git hooks unchecked (`--no-verify`).
|
||||
#[serde(default)]
|
||||
no_verify: bool,
|
||||
},
|
||||
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
|
||||
DeleteBranch {
|
||||
@@ -133,23 +125,6 @@ pub enum RemoteOp {
|
||||
target: Option<String>,
|
||||
#[serde(default)]
|
||||
remote: Option<String>,
|
||||
#[serde(default)]
|
||||
force: bool,
|
||||
/// Record the target as upstream (Push dialog › Set upstream).
|
||||
#[serde(default)]
|
||||
set_upstream: bool,
|
||||
#[serde(default)]
|
||||
no_verify: bool,
|
||||
},
|
||||
/// Push Tag › Push to `remote`.
|
||||
PushTag {
|
||||
tag: String,
|
||||
remote: String,
|
||||
},
|
||||
/// Deletes tag `tag` on `remote` (Delete on Remote).
|
||||
DeleteRemoteTag {
|
||||
tag: String,
|
||||
remote: String,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
@@ -180,15 +155,11 @@ pub struct GitExecutableInfo {
|
||||
pub enum GitOperation {
|
||||
/// Continue the merge / rebase / cherry-pick / revert in progress.
|
||||
Continue,
|
||||
/// Stage and Retry: stage the changes of tracked files, then continue.
|
||||
StageAndContinue,
|
||||
Abort,
|
||||
Skip,
|
||||
/// Commits oldest first; `record_origin` adds "(cherry picked from commit …)".
|
||||
/// Commits oldest first.
|
||||
CherryPick {
|
||||
commits: Vec<String>,
|
||||
#[serde(default, rename = "recordOrigin")]
|
||||
record_origin: bool,
|
||||
},
|
||||
/// Commits newest first.
|
||||
Revert {
|
||||
@@ -601,33 +572,6 @@ pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, c
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
|
||||
/// Edit Commit Message: rewrites the commit and its descendants without touching the
|
||||
/// working tree.
|
||||
pub async fn vcs_reword(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
message: String,
|
||||
) -> IdeResult<ide_vcs::HeadChange> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.reword(&commit, &message))).await
|
||||
}
|
||||
|
||||
/// Undo of a history rewrite (Edit Commit Message, Drop, Squash).
|
||||
pub async fn vcs_restore_head(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
before: String,
|
||||
after: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_head(&before, &after))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_delete_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -640,19 +584,6 @@ pub async fn vcs_delete_branch(
|
||||
}
|
||||
|
||||
/// "Restore" of the branch-deleted notification.
|
||||
/// Deletes a local tag; the result restores it.
|
||||
pub async fn vcs_delete_tag(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<DeletedTag> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_tag(&name))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_restore_tag(host: &Host, window: &str, repo: Option<PathBuf>, deleted: DeletedTag) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_tag(&deleted))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_restore_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -748,42 +679,26 @@ pub async fn vcs_remote(
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. } | RemoteOp::ResetToUpstream)
|
||||
.then_some("Git pull");
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
RemoteOp::Fetch => r.fetch(&cancel, &out),
|
||||
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
|
||||
RemoteOp::ResetToUpstream => r.reset_to_upstream(&cancel, &out),
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags, force, no_verify } => {
|
||||
r.push_with(PushOptions { force_with_lease, force, set_upstream, tags, no_verify }, &cancel, &out)
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch {
|
||||
branch,
|
||||
revision,
|
||||
force_with_lease,
|
||||
target,
|
||||
remote,
|
||||
force,
|
||||
set_upstream,
|
||||
no_verify,
|
||||
} => r.push_branch_to(
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target, remote } => r.push_branch_to(
|
||||
&branch,
|
||||
PushBranchOptions {
|
||||
revision: revision.as_deref(),
|
||||
target: target.as_deref(),
|
||||
remote: remote.as_deref(),
|
||||
force_with_lease,
|
||||
force,
|
||||
set_upstream,
|
||||
no_verify,
|
||||
},
|
||||
&cancel,
|
||||
&out,
|
||||
),
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushTag { tag, remote } => r.push_tag(&tag, &remote, &cancel, &out),
|
||||
RemoteOp::DeleteRemoteTag { tag, remote } => r.delete_remote_tag(&tag, &remote, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
}
|
||||
@@ -919,12 +834,9 @@ pub async fn vcs_operation(
|
||||
};
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
GitOperation::Continue => r.continue_operation(&cancel, &out),
|
||||
GitOperation::StageAndContinue => r.stage_and_continue(&cancel, &out),
|
||||
GitOperation::Abort => r.abort_operation(&cancel, &out),
|
||||
GitOperation::Skip => r.skip_operation(&cancel, &out),
|
||||
GitOperation::CherryPick { commits, record_origin } => {
|
||||
r.cherry_pick_with(&commits, record_origin, &cancel, &out)
|
||||
}
|
||||
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
|
||||
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
|
||||
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
|
||||
GitOperation::InteractiveRebase { base, entries } => {
|
||||
@@ -1108,12 +1020,6 @@ pub async fn vcs_diff_numstat(
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
|
||||
}
|
||||
|
||||
/// Whether the repository has the executable Git hook `name` (Push dialog › Run Git hooks).
|
||||
pub async fn vcs_has_hook(host: &Host, window: &str, repo: Option<PathBuf>, name: String) -> IdeResult<bool> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.has_hook(&name))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
@@ -1341,22 +1247,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"vcs_undo_commit",
|
||||
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
|
||||
);
|
||||
d.add("vcs_reword", |c| async move {
|
||||
vcs_reword(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?, c.arg("message")?).await
|
||||
});
|
||||
d.add("vcs_restore_head", |c| async move {
|
||||
vcs_restore_head(&c.host, &c.window, c.arg("repo")?, c.arg("before")?, c.arg("after")?).await
|
||||
});
|
||||
d.add("vcs_delete_branch", |c| async move {
|
||||
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_delete_tag",
|
||||
|c| async move { vcs_delete_tag(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await },
|
||||
);
|
||||
d.add("vcs_restore_tag", |c| async move {
|
||||
vcs_restore_tag(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_restore_branch", |c| async move {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
@@ -1480,7 +1373,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_numstat", |c| async move {
|
||||
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
|
||||
});
|
||||
d.add("vcs_has_hook", |c| async move { vcs_has_hook(&c.host, &c.window, c.arg("repo")?, c.arg("name")?).await });
|
||||
d.add("vcs_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_plugin::Invocation;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
pub struct NavigationFiles {
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
pub indexing: bool,
|
||||
pub extensions: Vec<String>,
|
||||
}
|
||||
|
||||
/// File discovery is separate from clicking: the UI schedules small background
|
||||
/// batches after initial index completion and updates only changed files later.
|
||||
pub async fn plugin_navigation_files(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
) -> IdeResult<NavigationFiles> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if extensions.is_empty() {
|
||||
return Ok(NavigationFiles { paths: vec![], indexing: false, extensions: vec![] });
|
||||
}
|
||||
let indexing = project.index.status().indexing;
|
||||
let files = project.index.files_with_extensions(&extensions, 20_001);
|
||||
if files.len() > 20_000 {
|
||||
return Err(IdeError::unavailable("plugin navigation supports up to 20,000 matching files"));
|
||||
}
|
||||
Ok(NavigationFiles {
|
||||
paths: files.into_iter().filter(|p| p.starts_with(&project.root)).map(Into::into).collect(),
|
||||
indexing,
|
||||
extensions,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn plugin_navigation_invalidate(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Option<Vec<PathBuf>>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
plugins.invalidate_client_navigation(&project.root, Some(window), paths.as_deref());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_index(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
request: ide_core::RequestId,
|
||||
) -> IdeResult<()> {
|
||||
if paths.len() > 16 {
|
||||
return Err(IdeError::invalid("navigation batches are limited to 16 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let client = window.to_owned();
|
||||
let result = blocking(move || {
|
||||
if !project.is_trusted() {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
for path in paths {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let Ok(path) = ide_plugin::resolve_in_root(&project.root, &path.to_string_lossy()) else {
|
||||
continue;
|
||||
};
|
||||
let extensions = plugins.navigation_extensions(&project.root);
|
||||
if !path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)) {
|
||||
continue;
|
||||
}
|
||||
let document = project.documents.by_path(&path);
|
||||
let (text, version, language) = if let Some(id) = document {
|
||||
let snapshot = project.documents.text_snapshot(id)?;
|
||||
if snapshot.large {
|
||||
continue;
|
||||
}
|
||||
(snapshot.text, Some(snapshot.version), snapshot.language)
|
||||
} else {
|
||||
use std::io::Read;
|
||||
if !std::fs::metadata(&path)
|
||||
.is_ok_and(|m| m.is_file() && m.len() <= crate::plugins::navigation::MAX_DOCUMENT_BYTES as u64)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let Ok(file) = std::fs::File::open(&path) else {
|
||||
continue;
|
||||
};
|
||||
let mut text = String::new();
|
||||
if file
|
||||
.take((crate::plugins::navigation::MAX_DOCUMENT_BYTES + 1) as u64)
|
||||
.read_to_string(&mut text)
|
||||
.is_err()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let language = project.languages.detect(&path, text.lines().next());
|
||||
(text, None, language)
|
||||
};
|
||||
if text.len() > crate::plugins::navigation::MAX_DOCUMENT_BYTES {
|
||||
continue;
|
||||
}
|
||||
let symbols = project
|
||||
.index
|
||||
.symbols_for_text(&path, &text)
|
||||
.unwrap_or_else(|| project.syntax.syntax().tags(&language, &text));
|
||||
let roots = project.roots();
|
||||
let scope = roots
|
||||
.roots
|
||||
.iter()
|
||||
.filter(|r| path.starts_with(project.root.join(&r.path)))
|
||||
.max_by_key(|r| r.path.len())
|
||||
.and_then(|r| r.detected_from.as_deref())
|
||||
.unwrap_or("");
|
||||
plugins.index_navigation(
|
||||
&project.root,
|
||||
crate::plugins::navigation::Snapshot { path: &path, version, text: &text, symbols: &symbols, scope },
|
||||
Invocation { client: Some(client.clone()), workspace_root: Some(project.root.clone()), editor: None },
|
||||
)?;
|
||||
// A document edit while WASM was executing must not publish stale ranges.
|
||||
if cancel.is_cancelled() || document.is_some_and(|id| project.documents.version(id).ok() != version) {
|
||||
plugins.invalidate_client_navigation(&project.root, Some(&client), Some(&[path]));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_goto(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
doc: ide_core::DocId,
|
||||
pos: ide_core::Pos,
|
||||
) -> IdeResult<Option<crate::plugins::navigation::NavigationResult>> {
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
let (path, version) = project.documents.identity(doc)?;
|
||||
Ok(plugins.navigation_at(&project.root, &path, version, pos, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn plugin_navigation_markers(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<crate::plugins::navigation::FileMarkers>> {
|
||||
if paths.len() > 64 {
|
||||
return Err(IdeError::invalid("marker batches are limited to 64 files"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
let client = window.to_owned();
|
||||
blocking(move || {
|
||||
Ok(plugins.navigation_markers(&project.root, &paths, Some(&client), |p| {
|
||||
project.documents.by_path(p).and_then(|id| project.documents.version(id).ok())
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
@@ -20,11 +20,9 @@ mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
pub mod navigation;
|
||||
mod points;
|
||||
mod providers;
|
||||
pub mod remote;
|
||||
@@ -43,8 +41,8 @@ pub use market::{
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
};
|
||||
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
|
||||
pub(crate) use providers::editor_providers as editor_providers_for;
|
||||
pub use providers::{ProviderKind, set_editor_providers};
|
||||
pub(crate) use providers::{editor_plugin_host, editor_providers as editor_providers_for};
|
||||
pub use theme::{ThemeDef, ThemeKind};
|
||||
pub use types::*;
|
||||
|
||||
@@ -310,7 +308,6 @@ pub struct PluginHost {
|
||||
run_detectors: Arc<run_detectors::RunDetectors>,
|
||||
/// `[[contributes.providers]]` of enabled plugins.
|
||||
providers: Arc<providers::Providers>,
|
||||
navigation: Mutex<navigation::NavigationIndex>,
|
||||
/// Validated UI declarations of installed packages (start-up reuses them).
|
||||
index: Arc<ContributionIndex>,
|
||||
ledger: Mutex<Ledger>,
|
||||
@@ -399,7 +396,6 @@ impl PluginHost {
|
||||
languages,
|
||||
run_detectors,
|
||||
providers,
|
||||
navigation: Mutex::default(),
|
||||
index,
|
||||
plugins: RwLock::default(),
|
||||
state: Mutex::new(state),
|
||||
@@ -1885,7 +1881,6 @@ impl PluginHost {
|
||||
}
|
||||
}
|
||||
let Some(error) = actor.take_crash() else { return };
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
if error.starts_with(CANCELLED) {
|
||||
// Cancelled on request: restart on next use, no backoff.
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -1957,7 +1952,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn stop(&self, plugin: &LoadedPlugin) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(true);
|
||||
}
|
||||
@@ -1967,7 +1961,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn set_failed(&self, plugin: &LoadedPlugin, error: String) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "{error}");
|
||||
self.registry.dispose_plugin(plugin.id());
|
||||
let mut meta = plugin.meta.lock();
|
||||
@@ -1976,7 +1969,6 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
fn fail(&self, plugin: &LoadedPlugin, error: &str, client: Option<&str>) {
|
||||
self.clear_plugin_navigation(plugin.id());
|
||||
tracing::warn!(plugin = %plugin.id(), "disabled after failure: {error}");
|
||||
if let Some(actor) = plugin.runtime.lock().take() {
|
||||
actor.stop(false);
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
//! API 1.x document indexers: background snapshots become project-scoped
|
||||
//! associations. Navigation and gutter markers only read validated memory indexes.
|
||||
use super::PluginHost;
|
||||
use ide_core::{IdeError, IdeResult, Location, Pos, Range};
|
||||
use ide_extension::{Capability, Disposable, ExtensionPoint, Package, Placement, Prepared, decode};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::Invocation;
|
||||
use serde::Deserialize;
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
|
||||
const MAX_FILES: usize = 20_000;
|
||||
const MAX_EDGES: usize = 4096;
|
||||
|
||||
pub struct Snapshot<'a> {
|
||||
pub path: &'a Path,
|
||||
pub version: Option<u32>,
|
||||
pub text: &'a str,
|
||||
pub symbols: &'a [SymbolTag],
|
||||
pub scope: &'a str,
|
||||
}
|
||||
|
||||
pub use ide_plugin::indexing::*;
|
||||
|
||||
struct File {
|
||||
version: Option<u32>,
|
||||
scope: String,
|
||||
edges: Edges,
|
||||
}
|
||||
#[derive(Default)]
|
||||
struct Graph {
|
||||
instance: Weak<super::LoadedPlugin>,
|
||||
files: BTreeMap<PathBuf, File>,
|
||||
definitions: HashMap<String, Vec<Location>>,
|
||||
aliases: HashMap<String, Vec<(PathBuf, String)>>,
|
||||
edges: usize,
|
||||
}
|
||||
impl Graph {
|
||||
fn targets(&self, key: &str) -> Vec<&Location> {
|
||||
let mut keys = vec![key.to_owned()];
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut targets = vec![];
|
||||
while let Some(key) = keys.pop() {
|
||||
if seen.len() >= 64 || !seen.insert(key.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(locations) = self.definitions.get(&key) {
|
||||
targets.extend(locations.iter().take(256usize.saturating_sub(targets.len())));
|
||||
continue;
|
||||
}
|
||||
if let Some((prefix, suffix)) = key.split_once('#')
|
||||
&& let Some(aliases) = self.aliases.get(&format!("{prefix}#"))
|
||||
{
|
||||
for (_, target) in aliases.iter().take(64) {
|
||||
keys.push(format!("{target}{suffix}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
targets
|
||||
}
|
||||
fn remove(&mut self, path: &Path) {
|
||||
if let Some(file) = self.files.remove(path) {
|
||||
self.edges -= file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in file.edges.aliases {
|
||||
if let Some(entries) = self.aliases.get_mut(&alias.prefix) {
|
||||
entries.retain(|(p, _)| p != path);
|
||||
if entries.is_empty() {
|
||||
self.aliases.remove(&alias.prefix);
|
||||
}
|
||||
}
|
||||
}
|
||||
for anchor in file.edges.definitions {
|
||||
if let Some(locations) = self.definitions.get_mut(&anchor.key) {
|
||||
locations.retain(|l| l.path.0.as_path() != path);
|
||||
if locations.is_empty() {
|
||||
self.definitions.remove(&anchor.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
fn insert(&mut self, path: PathBuf, file: File) {
|
||||
self.remove(&path);
|
||||
self.edges += file.edges.definitions.len()
|
||||
+ file.edges.references.len()
|
||||
+ file.edges.aliases.len()
|
||||
+ file.edges.markers.len();
|
||||
for alias in &file.edges.aliases {
|
||||
self.aliases.entry(alias.prefix.clone()).or_default().push((path.clone(), alias.target.clone()));
|
||||
}
|
||||
for anchor in &file.edges.definitions {
|
||||
self.definitions.entry(anchor.key.clone()).or_default().push(Location {
|
||||
path: path.clone().into(),
|
||||
range: anchor.range,
|
||||
uri: None,
|
||||
});
|
||||
}
|
||||
self.files.insert(path, file);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct NavigationIndex {
|
||||
projects: HashMap<(PathBuf, Option<String>), HashMap<String, Graph>>,
|
||||
generation: u64,
|
||||
epochs: HashMap<PathBuf, u64>,
|
||||
}
|
||||
|
||||
fn contains(range: Range, pos: Pos) -> bool {
|
||||
(range.start.line, range.start.col) <= (pos.line, pos.col) && (pos.line, pos.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
fn valid_range(lines: &[usize], range: Range) -> bool {
|
||||
let pos = |p: Pos| lines.get(p.line as usize).is_some_and(|&length| p.col as usize <= length);
|
||||
pos(range.start) && pos(range.end) && (range.start.line, range.start.col) < (range.end.line, range.end.col)
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(crate) fn clear_plugin_navigation(&self, id: &str) {
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
let roots: Vec<_> = index.projects.keys().cloned().collect();
|
||||
for root in roots {
|
||||
index.epochs.insert(root.0, epoch);
|
||||
}
|
||||
for graphs in index.projects.values_mut() {
|
||||
graphs.remove(id);
|
||||
}
|
||||
drop(index);
|
||||
self.emit(None, super::PluginEvent::DocumentIndexesChanged);
|
||||
}
|
||||
|
||||
/// Extensions with an active, permissioned navigation provider for this root.
|
||||
pub fn navigation_extensions(&self, root: &Path) -> Vec<String> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
self.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| p.status().is_live() && p.dev_root.as_deref().is_none_or(|r| r == root))
|
||||
.filter_map(|p| declaration(p))
|
||||
.flat_map(|d| d.extensions)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Remove stale contributions before scheduling replacement work. `None`
|
||||
/// disposes the project, including reverse-index entries, on project close.
|
||||
pub fn invalidate_navigation(&self, root: &Path, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, None, paths);
|
||||
}
|
||||
|
||||
pub fn invalidate_client_navigation(&self, root: &Path, client: Option<&str>, paths: Option<&[PathBuf]>) {
|
||||
self.invalidate_navigation_scope(root, Some(client), paths);
|
||||
}
|
||||
|
||||
fn invalidate_navigation_scope(&self, root: &Path, client: Option<Option<&str>>, paths: Option<&[PathBuf]>) {
|
||||
let matches = |key: &(PathBuf, Option<String>)| key.0 == root && client.is_none_or(|c| key.1.as_deref() == c);
|
||||
let mut index = self.navigation.lock();
|
||||
index.generation = index.generation.wrapping_add(1);
|
||||
let epoch = index.generation;
|
||||
index.epochs.insert(root.to_owned(), epoch);
|
||||
if index.epochs.len() > 64
|
||||
&& let Some(oldest) = index.epochs.iter().min_by_key(|(_, v)| **v).map(|(p, _)| p.clone())
|
||||
{
|
||||
index.epochs.remove(&oldest);
|
||||
}
|
||||
match paths {
|
||||
None => {
|
||||
index.projects.retain(|key, _| !matches(key));
|
||||
}
|
||||
Some(paths) => {
|
||||
for graphs in index.projects.iter_mut().filter(|(key, _)| matches(key)).map(|(_, graphs)| graphs) {
|
||||
for graph in graphs.values_mut() {
|
||||
for path in paths {
|
||||
graph.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Index one bounded snapshot. No directory traversal or parsing happens in
|
||||
/// navigation_at. Call off the UI thread, in small cancellable batches.
|
||||
pub fn index_navigation(&self, root: &Path, snapshot: Snapshot<'_>, invocation: Invocation) -> IdeResult<()> {
|
||||
let Snapshot { path, version, text, symbols, scope } = snapshot;
|
||||
if !(self.project_policy.read())(root) {
|
||||
return Err(IdeError::unavailable("project is not trusted"));
|
||||
}
|
||||
if text.len() > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("navigation document is too large"));
|
||||
}
|
||||
let real = ide_plugin::resolve_in_root(root, &path.to_string_lossy()).map_err(IdeError::invalid)?;
|
||||
if real != path {
|
||||
return Err(IdeError::invalid("navigation requires a canonical project path"));
|
||||
}
|
||||
let project_key = (root.to_owned(), invocation.client.clone());
|
||||
let generation = self.navigation.lock().epochs.get(root).copied();
|
||||
let extension = path.extension().and_then(|e| e.to_str()).unwrap_or_default();
|
||||
let plugins: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.values()
|
||||
.filter(|p| {
|
||||
p.status().is_live()
|
||||
&& p.dev_root.as_deref().is_none_or(|r| r == root)
|
||||
&& p.meta.lock().granted.contains(&Capability::ProjectRead)
|
||||
&& declaration(p).is_some_and(|n| n.extensions.iter().any(|e| e == extension))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if plugins.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let lines: Vec<_> = text.split('\n').map(|l| l.encode_utf16().count()).collect();
|
||||
for plugin in plugins {
|
||||
let (actor, activated) = self.ensure_active(&plugin, invocation.client.as_deref())?;
|
||||
if activated {
|
||||
self.changed();
|
||||
}
|
||||
let (owned_path, owned_text) = (path.to_string_lossy().into_owned(), text.to_owned());
|
||||
let symbols = symbols
|
||||
.iter()
|
||||
.map(|s| ide_plugin::indexing::IndexSymbol {
|
||||
name: s.name.clone(),
|
||||
kind: serde_json::to_value(s.kind)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(str::to_owned))
|
||||
.unwrap_or_default(),
|
||||
container: s.container.clone(),
|
||||
range: s.range,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let ctx = invocation.clone();
|
||||
let started = std::time::Instant::now();
|
||||
let result = actor.call(None, move |rt| rt.index_document(&owned_path, &owned_text, &symbols, ctx));
|
||||
let edges = self
|
||||
.finish_provider_call(&plugin, &actor, started, result)
|
||||
.ok_or_else(|| IdeError::unavailable("document indexer did not return a result"))?;
|
||||
let output_bytes = edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.chain(&edges.markers)
|
||||
.map(|a| a.key.len().saturating_add(32))
|
||||
.sum::<usize>()
|
||||
.saturating_add(edges.aliases.iter().map(|a| a.prefix.len() + a.target.len()).sum::<usize>());
|
||||
if output_bytes > MAX_DOCUMENT_BYTES {
|
||||
return Err(IdeError::invalid("document index output is too large"));
|
||||
}
|
||||
if edges.aliases.len() > 256
|
||||
|| edges.aliases.iter().any(|a| {
|
||||
!a.prefix.ends_with('#')
|
||||
|| !a.target.ends_with('#')
|
||||
|| a.prefix.len() > 1024
|
||||
|| a.target.len() > 1024
|
||||
})
|
||||
|| edges.definitions.len() + edges.references.len() > MAX_EDGES
|
||||
|| edges
|
||||
.definitions
|
||||
.iter()
|
||||
.chain(&edges.references)
|
||||
.any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation anchors"));
|
||||
}
|
||||
if edges.markers.len() > MAX_EDGES
|
||||
|| edges.markers.iter().any(|a| a.key.is_empty() || a.key.len() > 1024 || !valid_range(&lines, a.range))
|
||||
{
|
||||
return Err(IdeError::invalid("invalid navigation markers"));
|
||||
}
|
||||
let mut index = self.navigation.lock();
|
||||
if index.epochs.get(root).copied() != generation {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
// Keep a bounded number of project graphs, including closed windows
|
||||
// whose frontend did not get an opportunity to dispose its state.
|
||||
if index.projects.len() >= 16 && !index.projects.contains_key(&project_key) {
|
||||
return Err(IdeError::unavailable("navigation project limit reached"));
|
||||
}
|
||||
let graph =
|
||||
index.projects.entry(project_key.clone()).or_default().entry(plugin.id().to_owned()).or_default();
|
||||
if !graph.instance.ptr_eq(&Arc::downgrade(&plugin)) {
|
||||
*graph = Graph { instance: Arc::downgrade(&plugin), ..Default::default() };
|
||||
}
|
||||
if graph.files.len() >= MAX_FILES && !graph.files.contains_key(path) {
|
||||
return Err(IdeError::unavailable("navigation file limit reached"));
|
||||
}
|
||||
graph.remove(path);
|
||||
if graph.edges
|
||||
+ edges.definitions.len()
|
||||
+ edges.references.len()
|
||||
+ edges.aliases.len()
|
||||
+ edges.markers.len()
|
||||
> 200_000
|
||||
{
|
||||
return Err(IdeError::unavailable("navigation association limit reached"));
|
||||
}
|
||||
graph.insert(path.to_owned(), File { version, scope: scope.to_owned(), edges });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolves only current live providers. Disable/reload/trap cannot retain
|
||||
/// navigable stale data, even if a background snapshot finished concurrently.
|
||||
pub fn navigation_at(
|
||||
&self,
|
||||
root: &Path,
|
||||
path: &Path,
|
||||
version: u32,
|
||||
pos: Pos,
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Option<NavigationResult> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return None;
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let graphs = index.projects.get(&(root.to_owned(), client.map(str::to_owned)))?;
|
||||
let mut result: Option<NavigationResult> = None;
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if file.version != Some(version) {
|
||||
continue;
|
||||
}
|
||||
for reference in &file.edges.references {
|
||||
if !contains(reference.range, pos) {
|
||||
continue;
|
||||
}
|
||||
let targets = graph.targets(&reference.key);
|
||||
if targets.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let answer = result.get_or_insert_with(|| NavigationResult { range: reference.range, targets: vec![] });
|
||||
let local = targets.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
for target in targets.iter().take(256) {
|
||||
let Some(target_file) = graph.files.get(&target.path.0) else {
|
||||
continue;
|
||||
};
|
||||
if local && target_file.scope != file.scope {
|
||||
continue;
|
||||
}
|
||||
if version_of(&target.path).is_some_and(|v| target_file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
if (target.path.0.as_path() != path || !contains(target.range, pos))
|
||||
&& !answer.targets.iter().any(|t| t.path == target.path && t.range == target.range)
|
||||
{
|
||||
answer.targets.push((*target).clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
result.filter(|r| !r.targets.is_empty())
|
||||
}
|
||||
/// Gutter markers of visible files. Uses only existing graph entries; never executes WASM.
|
||||
pub fn navigation_markers(
|
||||
&self,
|
||||
root: &Path,
|
||||
paths: &[PathBuf],
|
||||
client: Option<&str>,
|
||||
version_of: impl Fn(&Path) -> Option<u32>,
|
||||
) -> Vec<FileMarkers> {
|
||||
if !(self.project_policy.read())(root) {
|
||||
return vec![];
|
||||
}
|
||||
let plugins = self.plugins.read();
|
||||
let index = self.navigation.lock();
|
||||
let Some(graphs) = index.projects.get(&(root.to_owned(), client.map(str::to_owned))) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut result = vec![];
|
||||
for path in paths {
|
||||
for (id, graph) in graphs {
|
||||
let Some(plugin) = plugins.get(id) else { continue };
|
||||
if !plugin.status().is_live() || !graph.instance.ptr_eq(&Arc::downgrade(plugin)) {
|
||||
continue;
|
||||
}
|
||||
let Some(file) = graph.files.get(path) else { continue };
|
||||
if version_of(path).is_some_and(|v| file.version != Some(v)) {
|
||||
continue;
|
||||
}
|
||||
let mut markers = vec![];
|
||||
for anchor in &file.edges.markers {
|
||||
let candidates = graph.targets(&anchor.key);
|
||||
let local =
|
||||
candidates.iter().any(|t| graph.files.get(&t.path.0).is_some_and(|f| f.scope == file.scope));
|
||||
let mut targets = vec![];
|
||||
for target in candidates {
|
||||
let Some(other) = graph.files.get(&target.path.0) else { continue };
|
||||
if (local && other.scope != file.scope)
|
||||
|| version_of(&target.path).is_some_and(|v| other.version != Some(v))
|
||||
|| (target.path.0 == *path && target.range == anchor.range)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if !targets.iter().any(|t: &Location| t.path == target.path && t.range == target.range) {
|
||||
targets.push(target.clone());
|
||||
}
|
||||
}
|
||||
if !targets.is_empty() {
|
||||
markers.push(NavigationResult { range: anchor.range, targets });
|
||||
}
|
||||
}
|
||||
if !markers.is_empty() {
|
||||
result.push(FileMarkers { path: path.clone(), version: file.version, markers });
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Decl {
|
||||
extensions: Vec<String>,
|
||||
}
|
||||
fn declaration(p: &super::LoadedPlugin) -> Option<Decl> {
|
||||
p.manifest()
|
||||
.declarations("documentIndexers")
|
||||
.into_iter()
|
||||
.next()
|
||||
.and_then(|d| decode("documentIndexers", d.clone()).ok())
|
||||
}
|
||||
pub struct IndexersPoint;
|
||||
impl ExtensionPoint for IndexersPoint {
|
||||
fn id(&self) -> &'static str {
|
||||
"documentIndexers"
|
||||
}
|
||||
fn placement(&self) -> Placement {
|
||||
Placement::Workspace
|
||||
}
|
||||
fn prepare(&self, pkg: &Package, value: toml::Value) -> Result<Prepared, String> {
|
||||
let d: Decl = decode(self.id(), value)?;
|
||||
if pkg.manifest.wasm.is_none()
|
||||
|| d.extensions.is_empty()
|
||||
|| d.extensions.len() > 32
|
||||
|| d.extensions
|
||||
.iter()
|
||||
.any(|e| e.is_empty() || e.len() > 32 || !e.bytes().all(|c| c.is_ascii_alphanumeric()))
|
||||
{
|
||||
return Err("documentIndexers requires wasm and 1-32 file extensions".into());
|
||||
}
|
||||
if pkg.manifest.declarations(self.id()).len() > 1 {
|
||||
return Err("declare one document indexer per plugin".into());
|
||||
}
|
||||
Ok(Prepared::new(d.extensions.join(", "), || Ok(Disposable::new(|| {}))).capability(Capability::ProjectRead))
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,6 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(LanguagesPoint::new(languages.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::icons::POINT_ID, ui.clone(), super::icons::icon_theme)))?;
|
||||
registry.register(Arc::new(super::run_detectors::RunDetectorsPoint::new(run_detectors.clone())))?;
|
||||
registry.register(Arc::new(super::navigation::IndexersPoint))?;
|
||||
registry.register(Arc::new(super::providers::ProvidersPoint::new(providers.clone())))?;
|
||||
registry.register(Arc::new(UiPoint::new(super::providers::SECRETS_POINT, ui.clone(), super::providers::secret)))?;
|
||||
use super::ui_points as p;
|
||||
|
||||
@@ -180,14 +180,10 @@ pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
|
||||
|
||||
/// The plugin host whose providers take part, when any declares `kind`.
|
||||
pub(crate) fn editor_providers(kind: ProviderKind) -> Option<Arc<PluginHost>> {
|
||||
let host = editor_plugin_host()?;
|
||||
let host = EDITOR_PROVIDERS.read().as_ref()?.upgrade()?;
|
||||
host.providers.any(kind).then_some(host)
|
||||
}
|
||||
|
||||
pub(crate) fn editor_plugin_host() -> Option<Arc<PluginHost>> {
|
||||
EDITOR_PROVIDERS.read().as_ref()?.upgrade()
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
/// Usable plugins declaring `kind` for `language`.
|
||||
fn provider_plugins(&self, kind: ProviderKind, language: &str) -> Vec<Arc<LoadedPlugin>> {
|
||||
|
||||
@@ -448,44 +448,6 @@ impl RemotePlugins {
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_files", move |c| {
|
||||
let h = h.clone();
|
||||
async move { super::index_ops::plugin_navigation_files(&c.host, alive(&h)?, &c.window).await }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_invalidate", move |c| { let h = h.clone(); async move {
|
||||
super::index_ops::plugin_navigation_invalidate(&c.host, alive(&h)?, &c.window, c.arg("paths")?)
|
||||
} });
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_index", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_index(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("paths")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_goto", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_goto(&c.host, alive(&h)?, &c.window, c.arg("doc")?, c.arg("pos")?)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_navigation_markers", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::index_ops::plugin_navigation_markers(&c.host, alive(&h)?, &c.window, c.arg("paths")?).await
|
||||
}
|
||||
});
|
||||
d.add("plugin_line_markers", |c| async move {
|
||||
crate::ops::lsp::plugin_line_markers(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
|
||||
@@ -153,8 +153,6 @@ pub struct PluginUpdate {
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// Cached document associations were disposed (reload, disable or failure).
|
||||
DocumentIndexesChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
|
||||
@@ -526,7 +526,6 @@ impl Host {
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
project.lsp.prewarm_java_configuration();
|
||||
if let Some(bridge) = &project.kotlin_bridge {
|
||||
bridge.start(&self.runtime);
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ impl crate::Host {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
project.lsp.prewarm_java_configuration();
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
@@ -341,7 +340,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_diff_numstat"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_has_hook"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! Generic document-indexer contract through a minimal real WASM fixture.
|
||||
use ide_core::{Pos, Range, SymbolKind};
|
||||
use ide_host::plugins::navigation::Snapshot;
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_language::SymbolTag;
|
||||
use ide_plugin::{Invocation, Limits};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
struct Fixture {
|
||||
_tmp: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
host: PluginHost,
|
||||
}
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().canonicalize().unwrap();
|
||||
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
host.set_project_policy(Arc::new(|_| true));
|
||||
host.install_from_dir(
|
||||
&Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer"),
|
||||
&Consent::all(),
|
||||
)
|
||||
.unwrap();
|
||||
Self { _tmp: tmp, root, host }
|
||||
}
|
||||
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
|
||||
let path = self.root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(&path, text).unwrap();
|
||||
self.host
|
||||
.index_navigation(
|
||||
&self.root,
|
||||
Snapshot { path: &path, version, text, symbols, scope },
|
||||
Invocation { workspace_root: Some(self.root.clone()), ..Default::default() },
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
}
|
||||
fn method() -> SymbolTag {
|
||||
SymbolTag {
|
||||
name: "item".into(),
|
||||
kind: SymbolKind::Method,
|
||||
container: None,
|
||||
range: Range::new(Pos::new(0, 4), Pos::new(0, 8)),
|
||||
}
|
||||
}
|
||||
const SOURCE: &str = "def source:item\nref mapping:item\n";
|
||||
const MAPPING: &str = "def mapping:item\nref source:item\n";
|
||||
fn reference_pos() -> Pos {
|
||||
Pos::new(1, 5)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bidirectional_navigation_versions_and_incremental_deletion() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app");
|
||||
let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, source);
|
||||
assert_eq!(result.range.end.col - result.range.start.col, 11);
|
||||
let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(reverse.targets[0].path.0, mapping);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[source]));
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_candidates_reload_disable_and_project_isolation() {
|
||||
let f = Fixture::new();
|
||||
let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a");
|
||||
f.index("b/source.idx", SOURCE, &[method()], Some(1), "b");
|
||||
let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets.len(), 1);
|
||||
assert_eq!(result.targets[0].path.0, first);
|
||||
let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c");
|
||||
assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2);
|
||||
assert!(
|
||||
f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none()
|
||||
);
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.set_enabled("test.indexer", true).unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
f.host.reload("test.indexer").unwrap();
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn warmed_clicks_never_read_or_parse_files() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
// Files become unreadable; the watcher invalidates separately. Resolution
|
||||
// must remain purely in-memory, with no disk reads or component execution.
|
||||
std::fs::remove_file(&source).unwrap();
|
||||
std::fs::remove_file(&mapping).unwrap();
|
||||
let calls = f.host.info_of("test.indexer").unwrap().stats.calls;
|
||||
for _ in 0..10_000 {
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm");
|
||||
f.host.invalidate_navigation(&f.root, None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permissions_and_paths_are_checked_before_indexing() {
|
||||
let f = Fixture::new();
|
||||
let external = tempfile::NamedTempFile::new().unwrap();
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
f.host.set_project_policy(Arc::new(|_| false));
|
||||
assert!(f.host.navigation_extensions(&f.root).is_empty());
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" },
|
||||
Invocation::default()
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
|
||||
let f = Fixture::new();
|
||||
let symbol = method();
|
||||
let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app");
|
||||
f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app");
|
||||
let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap();
|
||||
assert_eq!(result.targets[0].path.0, definition);
|
||||
assert_eq!(result.targets[0].range, method().range);
|
||||
f.index("unicode.idx", "def 😀\n", &[], Some(1), "app");
|
||||
let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app");
|
||||
let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap();
|
||||
assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
|
||||
let f = Fixture::new();
|
||||
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
|
||||
for client in ["one", "two"] {
|
||||
let mut symbol = method();
|
||||
if client == "two" {
|
||||
symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9));
|
||||
}
|
||||
for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] {
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
|
||||
Invocation { client: Some(client.into()), workspace_root: Some(f.root.clone()), editor: None },
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
4
|
||||
);
|
||||
assert_eq!(
|
||||
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0]
|
||||
.range
|
||||
.start
|
||||
.col,
|
||||
5
|
||||
);
|
||||
f.host.invalidate_client_navigation(&f.root, Some("one"), None);
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none());
|
||||
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_and_disable_follow_graph_state() {
|
||||
let f = Fixture::new();
|
||||
let tags = vec![];
|
||||
let source = f.index("source.idx", SOURCE, &tags, Some(1), "app");
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
|
||||
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
|
||||
let source_markers = entries.iter().find(|e| e.path == source).unwrap();
|
||||
assert_eq!(source_markers.markers.len(), 1);
|
||||
assert_eq!(source_markers.markers[0].targets[0].path.0, mapping);
|
||||
let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap();
|
||||
assert_eq!(mapping_markers.markers.len(), 1);
|
||||
assert_eq!(mapping_markers.markers[0].targets[0].path.0, source);
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty());
|
||||
f.host.invalidate_navigation(&f.root, Some(&[mapping]));
|
||||
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
|
||||
f.host.set_enabled("test.indexer", false).unwrap();
|
||||
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ranges_and_oversized_documents_never_publish() {
|
||||
let f = Fixture::new();
|
||||
let path = f.root.join("invalid.idx");
|
||||
std::fs::write(&path, "invalid key\n").unwrap();
|
||||
for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] {
|
||||
assert!(
|
||||
f.host
|
||||
.index_navigation(
|
||||
&f.root,
|
||||
Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" },
|
||||
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
|
||||
}
|
||||
@@ -189,43 +189,6 @@ impl ProjectIndex {
|
||||
out
|
||||
}
|
||||
|
||||
/// Reuse indexed syntax tags only for the exact text snapshot. Unsaved or
|
||||
/// not-yet-indexed contents fall back to the caller's parser.
|
||||
pub fn symbols_for_text(&self, path: &Path, text: &str) -> Option<Vec<ide_language::SymbolTag>> {
|
||||
let hash = blake3::hash(text.as_bytes());
|
||||
let data = self.shared.data.read();
|
||||
let file = data.get(path)?;
|
||||
if &file.hash != hash.as_bytes() {
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
file.symbols
|
||||
.iter()
|
||||
.map(|s| ide_language::SymbolTag {
|
||||
name: s.name.to_string(),
|
||||
kind: s.kind,
|
||||
container: s.container.as_ref().map(ToString::to_string),
|
||||
range: s.range,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Bounded file inventory for background plugin indexers; honors the same
|
||||
/// project/exclude scope as search and never walks the filesystem.
|
||||
pub fn files_with_extensions(&self, extensions: &[String], limit: usize) -> Vec<PathBuf> {
|
||||
self.shared
|
||||
.data
|
||||
.read()
|
||||
.files
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|f| f.path.extension().and_then(|e| e.to_str()).is_some_and(|e| extensions.iter().any(|x| x == e)))
|
||||
.take(limit)
|
||||
.map(|f| f.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fuzzy file search. IDEA-like ranking: file-name matches beat directory
|
||||
/// matches; `/` in the query matches path segments; recently opened files boost.
|
||||
pub fn find_files(&self, query: &str, limit: usize) -> Vec<FileMatch> {
|
||||
|
||||
@@ -632,23 +632,3 @@ fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() {
|
||||
assert!(idx.wait_idle(WAIT));
|
||||
assert!(idx.find_files("", 100).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_inventory_respects_excludes_and_symbols_require_matching_text() {
|
||||
let java = "package demo; interface UserMapper { void find(); }";
|
||||
let f = Fixture::new(&[
|
||||
("src/UserMapper.java", java),
|
||||
("src/mapper.xml", "<mapper/>"),
|
||||
("target/Generated.java", "class Generated {}"),
|
||||
("other.txt", "ignored"),
|
||||
]);
|
||||
let index = f.open();
|
||||
let paths = index.files_with_extensions(&["java".into(), "xml".into()], 100);
|
||||
assert_eq!(paths.len(), 2);
|
||||
assert!(paths.iter().all(|p| p.starts_with(f.root.join("src"))));
|
||||
assert_eq!(index.files_with_extensions(&["java".into(), "xml".into()], 1).len(), 1);
|
||||
let path = f.root.join("src/UserMapper.java");
|
||||
let symbols = index.symbols_for_text(&path, java).unwrap();
|
||||
assert!(symbols.iter().any(|s| s.name == "find"));
|
||||
assert!(index.symbols_for_text(&path, &java.replace("find", "renamed")).is_none());
|
||||
}
|
||||
@@ -10,7 +10,6 @@ use std::sync::{Arc, OnceLock};
|
||||
/// Built-in definitions, embedded at compile time. Add new languages here.
|
||||
const BUILTIN: &[(&str, &str)] = &[
|
||||
("asciidoc", include_str!("../../../languages/asciidoc.toml")),
|
||||
("astro", include_str!("../../../languages/astro.toml")),
|
||||
("bat", include_str!("../../../languages/bat.toml")),
|
||||
("c", include_str!("../../../languages/c.toml")),
|
||||
("clojure", include_str!("../../../languages/clojure.toml")),
|
||||
@@ -603,7 +602,6 @@ mod tests {
|
||||
"Angular Template",
|
||||
"ERB",
|
||||
"JSP",
|
||||
"Astro",
|
||||
];
|
||||
|
||||
fn reg() -> LanguageRegistry {
|
||||
@@ -677,7 +675,7 @@ mod tests {
|
||||
let root = tmp.path();
|
||||
std::fs::create_dir_all(root.join("apps/web/src")).unwrap();
|
||||
std::fs::write(root.join("apps/web/.oxlintrc.json"), "{}").unwrap();
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte", "astro"] {
|
||||
for lang in ["javascript", "javascriptreact", "typescript", "typescriptreact", "vue", "svelte"] {
|
||||
let cfg = reg.get(&LanguageId::new(lang)).unwrap();
|
||||
let oxlint = cfg.language_servers.iter().find(|s| s.name == "oxlint").unwrap();
|
||||
assert_eq!((oxlint.command.as_str(), oxlint.args.as_slice()), ("oxlint", ["--lsp".to_string()].as_slice()));
|
||||
@@ -891,7 +889,6 @@ mod tests {
|
||||
("App.svelte", "svelte"),
|
||||
("index.jsp", "jsp"),
|
||||
("WEB-INF/tags/card.tag", "jsp"),
|
||||
("index.astro", "astro"),
|
||||
("package.json", "json"),
|
||||
("tsconfig.json", "jsonc"),
|
||||
("tsconfig.app.json", "jsonc"),
|
||||
@@ -953,14 +950,6 @@ mod tests {
|
||||
assert_eq!(res.line_comment.as_deref(), Some(";"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn astro_server_reads_the_project_typescript() {
|
||||
let reg = reg();
|
||||
let ls = ®.get(&LanguageId::new("astro")).unwrap().language_servers[0];
|
||||
let tsdk = ls.initialization_options.as_ref().and_then(|o| o.get("typescript")?.get("tsdk")?.as_str());
|
||||
assert_eq!(tsdk, Some("node_modules/typescript/lib"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_shebangs() {
|
||||
let reg = reg();
|
||||
|
||||
@@ -327,36 +327,6 @@ impl LspManager {
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
/// Start Java in trusted build-root projects during idle startup, so configuration
|
||||
/// navigation can warm after import even when no Java editor has been opened.
|
||||
pub fn prewarm_java_configuration(self: &Arc<Self>) {
|
||||
let weak = Arc::downgrade(self);
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
let Some(mgr) = weak.upgrade() else { return };
|
||||
if mgr.shutting_down.load(std::sync::atomic::Ordering::Acquire)
|
||||
|| !mgr.execution_allowed.load(std::sync::atomic::Ordering::Acquire)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let root = mgr.project_root.clone();
|
||||
let java_project = tokio::task::spawn_blocking(move || {
|
||||
["pom.xml", "build.gradle", "build.gradle.kts", ".classpath"]
|
||||
.iter()
|
||||
.any(|name| root.join(name).is_file())
|
||||
})
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if java_project {
|
||||
for server in mgr.servers_for(&LanguageId::new("java"), &mgr.project_root) {
|
||||
if server.name == SERVER {
|
||||
mgr.ensure_started(&server);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let mgr = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
@@ -364,32 +334,6 @@ impl LspManager {
|
||||
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
|
||||
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
|
||||
}
|
||||
// The extension only queues bounded, low-priority work; this request
|
||||
// never waits for the configuration index itself to be built.
|
||||
const WARMUP: &str = "openidea.java.configuration.warmup";
|
||||
if server.running_gen(generation).is_some()
|
||||
&& run.caps.read().commands().iter().any(|command| command == WARMUP)
|
||||
{
|
||||
let preferred: Vec<_> = mgr
|
||||
.docs
|
||||
.lock()
|
||||
.values()
|
||||
.filter(|doc| matches!(doc.language.as_str(), "yaml" | "properties"))
|
||||
.map(|doc| doc.uri.to_string())
|
||||
.collect();
|
||||
let result = tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({"command": WARMUP, "arguments": ["start", preferred]}),
|
||||
None,
|
||||
),
|
||||
)
|
||||
.await;
|
||||
if !matches!(result, Ok(Ok(_))) {
|
||||
tracing::debug!("jdtls: configuration warmup unavailable; retaining on-demand navigation");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -814,7 +758,7 @@ mod tests {
|
||||
for wrapped in [false, true] {
|
||||
let mut cfg = java_config();
|
||||
let settings = json!({"java": {"project": {"referencedLibraries": ["vendor/*.jar"]}}});
|
||||
cfg.settings = Some(if wrapped { json!({ "settings": settings }) } else { settings });
|
||||
cfg.settings = Some(if wrapped { json!({"settings": settings}) } else { settings });
|
||||
configure_source_paths(&mut cfg, Some(vec!["custom".into()]));
|
||||
let settings = crate::handler::all_settings(&cfg);
|
||||
assert_eq!(settings["java"]["project"]["referencedLibraries"], json!(["vendor/*.jar"]));
|
||||
|
||||
@@ -2377,7 +2377,7 @@ async fn class_file_opening_routes_sources_and_binary_documents() {
|
||||
let source = sources.join("p/Outer.java");
|
||||
std::fs::write(&source, "package p; class Outer {} ").unwrap();
|
||||
s.respond("java/classFileContents", json!("// Source code is decompiled from a .class file using FernFlower decompiler (from Intellij IDEA).\npackage p;\npublic class Outer {}"));
|
||||
s.respond("workspace/executeCommand", json!({ "org.eclipse.jdt.ls.core.sourcePaths": [sources] }));
|
||||
s.respond("workspace/executeCommand", json!({"org.eclipse.jdt.ls.core.sourcePaths":[sources]}));
|
||||
for class in ["Outer", "Outer$Nested", "Outer$1"] {
|
||||
let path = h.root.join(format!("target/classes/p/{class}.class"));
|
||||
let resolved = h.mgr.java_class_file(&path, None).await.unwrap();
|
||||
@@ -3394,7 +3394,7 @@ async fn gopls_interactive_command_validates_before_execution_and_applies_edits(
|
||||
let reply: Value = tokio::task::block_in_place(|| {
|
||||
tokio::runtime::Handle::current().block_on(s.peer().request(
|
||||
"workspace/applyEdit",
|
||||
json!({ "edit": edit }),
|
||||
json!({"edit": edit}),
|
||||
None,
|
||||
))
|
||||
})
|
||||
@@ -3679,54 +3679,6 @@ async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
assert_eq!(s.requested("textDocument/definition").len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_starts_java_without_an_open_editor_and_waits_for_import() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.configuration.warmup"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
let s = h.launcher.state("jdtls");
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.mgr.prewarm_java_configuration();
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
let config = h.root.join("application.yml");
|
||||
h.mgr.did_open(DocId::next(), &config, &LanguageId::new("yaml"), 1, "app: {}\n");
|
||||
s.peer().notify("language/status", json!({"type": "ServiceReady"})).unwrap();
|
||||
eventually(|| {
|
||||
s.requested("workspace/executeCommand").iter().any(|p| p["command"] == "openidea.java.configuration.warmup")
|
||||
})
|
||||
.await;
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
let warmup = requests.iter().find(|p| p["command"] == "openidea.java.configuration.warmup").unwrap();
|
||||
assert_eq!(warmup["arguments"], json!(["start", [uri_of(&config)]]));
|
||||
assert!(s.notified("textDocument/didOpen").is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_prewarm_respects_trust_shutdown_and_non_java_roots() {
|
||||
let mut fixtures = Vec::new();
|
||||
for mode in ["untrusted", "closed", "non-java"] {
|
||||
let h = harness_for("java", "java", &[("jdtls", incremental_caps())], &[]);
|
||||
if mode != "non-java" {
|
||||
std::fs::write(h.root.join("pom.xml"), "<project/>").unwrap();
|
||||
}
|
||||
if mode == "untrusted" {
|
||||
h.mgr.set_execution_allowed(false);
|
||||
}
|
||||
h.mgr.prewarm_java_configuration();
|
||||
if mode == "closed" {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
fixtures.push(h);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(2200)).await;
|
||||
for h in fixtures {
|
||||
assert_eq!(h.launcher.state("jdtls").launches.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/// The configuration command indexes the whole project, so Go to Definition (and
|
||||
/// every Ctrl+hover probe) consults it only inside a key argument; ordinary Java
|
||||
/// symbols in a Spring file go straight to JDT.
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
//! Edges a `document-indexer` export returns, and what the host answers from them.
|
||||
use ide_core::{Location, Range};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, Type)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Anchor {
|
||||
pub key: String,
|
||||
pub range: Range,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Alias {
|
||||
pub prefix: String,
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct Edges {
|
||||
pub definitions: Vec<Anchor>,
|
||||
pub references: Vec<Anchor>,
|
||||
#[serde(default)]
|
||||
pub aliases: Vec<Alias>,
|
||||
#[serde(default)]
|
||||
pub markers: Vec<Anchor>,
|
||||
}
|
||||
|
||||
/// The resolved gutter markers of one file.
|
||||
#[derive(Clone, Debug, Serialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileMarkers {
|
||||
pub path: PathBuf,
|
||||
pub version: Option<u32>,
|
||||
pub markers: Vec<NavigationResult>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigationResult {
|
||||
pub range: Range,
|
||||
pub targets: Vec<Location>,
|
||||
}
|
||||
|
||||
pub struct IndexSymbol {
|
||||
pub name: String,
|
||||
pub kind: String,
|
||||
pub container: Option<String>,
|
||||
pub range: Range,
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
//! host drops it. Each active plugin's runtime lives on its own [`Actor`] thread.
|
||||
|
||||
mod actor;
|
||||
pub mod indexing;
|
||||
mod runtime;
|
||||
mod sandbox;
|
||||
mod types;
|
||||
|
||||
@@ -11,7 +11,7 @@ use crate::types::{
|
||||
use crate::types::{
|
||||
FieldValue, View, ViewBody, ViewButton, ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{bindings, indexing_bindings, providers_bindings, syntax_bindings, views_bindings};
|
||||
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_extension::Capability;
|
||||
@@ -27,14 +27,13 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
|
||||
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
|
||||
/// The API version of the world above, `major.minor` of [`host_api`].
|
||||
pub const API_VERSION: &str = "1.2";
|
||||
pub const API_VERSION: &str = "1.1";
|
||||
const EPOCH_TICK: Duration = Duration::from_millis(10);
|
||||
pub(crate) const MAX_TEXT: usize = 1 << 20;
|
||||
pub(crate) const MAX_TITLE: usize = 256;
|
||||
@@ -485,7 +484,6 @@ struct Exports {
|
||||
plugin: bindings::Plugin,
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
}
|
||||
|
||||
@@ -562,7 +560,6 @@ impl Runtime {
|
||||
let pre = engine.linker.instantiate_pre(component).map_err(api_hint)?;
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
@@ -575,17 +572,13 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let indexer = match index_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer },
|
||||
exports: Exports { plugin, syntax, providers, views },
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -658,47 +651,6 @@ impl Runtime {
|
||||
self.call(Invocation::default(), timeout, |exports, store| f(exports.syntax.as_ref().expect("checked"), store))
|
||||
}
|
||||
|
||||
pub fn index_document(
|
||||
&mut self,
|
||||
path: &str,
|
||||
text: &str,
|
||||
symbols: &[crate::indexing::IndexSymbol],
|
||||
invocation: Invocation,
|
||||
) -> Result<crate::indexing::Edges, CallError> {
|
||||
if self.exports.indexer.is_none() {
|
||||
return Err(CallError::missing_export("document-indexer"));
|
||||
}
|
||||
let doc = wit_index::Document {
|
||||
path: path.into(),
|
||||
text: text.into(),
|
||||
symbols: symbols
|
||||
.iter()
|
||||
.map(|s| wit_index::Symbol {
|
||||
name: s.name.clone(),
|
||||
kind: s.kind.clone(),
|
||||
container: s.container.clone(),
|
||||
range: range_to_wit(s.range),
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
let edges = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
e.indexer.as_ref().expect("checked").call_index(store, &doc)
|
||||
})?;
|
||||
fn anchor(a: wit_index::Anchor) -> crate::indexing::Anchor {
|
||||
crate::indexing::Anchor { key: a.key, range: range_from_wit(a.range) }
|
||||
}
|
||||
Ok(crate::indexing::Edges {
|
||||
definitions: edges.definitions.into_iter().map(anchor).collect(),
|
||||
references: edges.references.into_iter().map(anchor).collect(),
|
||||
aliases: edges
|
||||
.aliases
|
||||
.into_iter()
|
||||
.map(|a| crate::indexing::Alias { prefix: a.prefix, target: a.target })
|
||||
.collect(),
|
||||
markers: edges.markers.into_iter().map(anchor).collect(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The component exports `providers`.
|
||||
pub fn has_providers(&self) -> bool {
|
||||
self.exports.providers.is_some()
|
||||
|
||||
@@ -696,10 +696,3 @@ impl views_bindings::openidea::plugin::view_types::Host for StoreData {}
|
||||
|
||||
/// Largest message between a plugin and its view's frame.
|
||||
const MAX_VIEW_MESSAGE: usize = 1 << 20;
|
||||
|
||||
pub(crate) mod indexing_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "indexing", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
@@ -23,12 +23,9 @@ fn repo() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../..")
|
||||
}
|
||||
|
||||
/// Official components plus fixtures for the base and optional indexing exports.
|
||||
/// Every component the repository builds for users: the official plugins with code, plus the `hello` fixture.
|
||||
fn components() -> Vec<PathBuf> {
|
||||
let mut out = vec![
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
];
|
||||
let mut out = vec![repo().join("crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm")];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
dirs.sort();
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
42044c4b2e3d768b922d6e5026ff4e725d37b265896ea7f383f1d737e2feffb7
|
||||
455d848dd27f242bb3e9bf420558cbc8c200853f51ada3d481635ac9771803f3
|
||||
-333
@@ -1,333 +0,0 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-indexer"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -1,20 +0,0 @@
|
||||
[package]
|
||||
name = "openidea-test-indexer"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -1,10 +0,0 @@
|
||||
# Document indexer test fixture
|
||||
|
||||
A small line-oriented test language (`.idx`), unrelated to any framework:
|
||||
`def key`, `ref key`, `alias prefix# target#` and `symbols prefix`.
|
||||
References also produce gutter markers. `symbols` uses host-supplied UTF-16
|
||||
syntax ranges. `invalid key` deliberately returns an out-of-bounds range.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_indexing` to exercise the optional
|
||||
WIT export, lifecycle invalidation, permissions, per-window isolation, candidate
|
||||
selection and warm memory-only lookups. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -1 +0,0 @@
|
||||
87eb2a28875abbc649b98d2a7f87263da217f9ad28f1395d96a61db5f781d9f0
|
||||
@@ -1,11 +0,0 @@
|
||||
id = "test.indexer"
|
||||
name = "Document indexer fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.2"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[capabilities]
|
||||
required = ["project.read"]
|
||||
|
||||
[[contributes.documentIndexers]]
|
||||
extensions = ["idx"]
|
||||
@@ -1,51 +0,0 @@
|
||||
//! Deliberately tiny line-oriented fixture, not a language/framework plugin.
|
||||
use openidea_plugin::indexing::{Alias, Anchor, Document, DocumentIndexer, Edges};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Position, Range, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Indexer;
|
||||
impl Plugin for Indexer {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
impl DocumentIndexer for Indexer {
|
||||
fn index(&mut self, doc: &Document) -> Edges {
|
||||
let mut out = Edges::default();
|
||||
for (line, text) in doc.text.lines().enumerate() {
|
||||
let Some((kind, value)) = text.split_once(' ') else { continue };
|
||||
let range = Range {
|
||||
start: Position { line: line as u32, character: kind.len() as u32 + 1 },
|
||||
end: Position { line: line as u32, character: text.encode_utf16().count() as u32 },
|
||||
};
|
||||
let anchor = Anchor { key: value.into(), range };
|
||||
match kind {
|
||||
"def" => out.definitions.push(anchor),
|
||||
"ref" => {
|
||||
out.references.push(anchor.clone());
|
||||
out.markers.push(anchor);
|
||||
}
|
||||
"alias" => {
|
||||
if let Some((prefix, target)) = value.split_once(' ') {
|
||||
out.aliases.push(Alias { prefix: prefix.into(), target: target.into() });
|
||||
}
|
||||
}
|
||||
"symbols" => out.definitions.extend(
|
||||
doc.symbols
|
||||
.iter()
|
||||
.map(|symbol| Anchor { key: format!("{value}{}", symbol.name), range: symbol.range }),
|
||||
),
|
||||
"invalid" => out.references.push(Anchor {
|
||||
key: value.into(),
|
||||
range: Range {
|
||||
start: Position { line: 999, character: 0 },
|
||||
end: Position { line: 999, character: 1 },
|
||||
},
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
register_plugin!(Indexer, indexing);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
3d3e43d4e49efa7a97984335f7f2df2cb286321bd0368251f37818aa6c1b70ad
|
||||
f7e1ae06a90460ed1d2319bfc51f848654df145fa451f3d959b32776b2b25ce3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
1d8561e04cb777efbafc6f64963473b9b69654a72ebb2be34f49c6c53c6f76e2
|
||||
12973c7ca7330fc50eb16718fd769d412401b5a92bd014f4804659e4a272122f
|
||||
+2
-2
@@ -5,8 +5,8 @@ wit_bindgen::generate!({
|
||||
inline: "
|
||||
package fixture:misbehave;
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.2.0;
|
||||
export openidea:plugin/providers@1.2.0;
|
||||
include openidea:plugin/plugin-syntax@1.1.0;
|
||||
export openidea:plugin/providers@1.1.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
|
||||
@@ -193,10 +193,8 @@ pub fn compose_container_root(config_json: &str, service: &str, root: &Path) ->
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[cfg(unix)]
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn config(command: &str, args: &[&str]) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "app".into(),
|
||||
@@ -214,7 +212,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn run<'a>(
|
||||
target: &'a RunTarget,
|
||||
endpoint: &'a BTreeMap<String, String>,
|
||||
|
||||
@@ -880,7 +880,7 @@ mod tests {
|
||||
configure_jdtls(&mut plain, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], None);
|
||||
let data = |c: &LanguageServerConfig| c.args[c.args.iter().position(|a| a == "-data").unwrap() + 1].clone();
|
||||
assert_ne!(data(&cfg), data(&plain), "a separate workspace, so everything is rebuilt with the agent");
|
||||
let patched = Path::new("/managed/lombok-1.18.48-openidea.3.jar");
|
||||
let patched = Path::new("/managed/lombok-1.18.48-openidea.2.jar");
|
||||
let mut fixed = jdtls();
|
||||
configure_jdtls(
|
||||
&mut fixed,
|
||||
@@ -894,23 +894,6 @@ mod tests {
|
||||
Some(patched),
|
||||
);
|
||||
assert_ne!(data(&cfg), data(&fixed), "the patch must rebuild classes compiled with the official agent");
|
||||
let mut previous = jdtls();
|
||||
configure_jdtls(
|
||||
&mut previous,
|
||||
Path::new("/p"),
|
||||
Path::new("/data"),
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
JDTLS_MIN_JAVA,
|
||||
&[],
|
||||
Some(Path::new("/managed/lombok-1.18.48-openidea.2.jar")),
|
||||
);
|
||||
assert_ne!(
|
||||
data(&previous),
|
||||
data(&fixed),
|
||||
"the new patch must rebuild classes compiled with the previous bundle"
|
||||
);
|
||||
let before = cfg.clone();
|
||||
configure_jdtls(&mut cfg, Path::new("/p"), Path::new("/data"), &[], None, None, JDTLS_MIN_JAVA, &[], Some(jar));
|
||||
assert_eq!(cfg, before, "idempotent");
|
||||
@@ -1186,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.6.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.5.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -507,7 +507,7 @@ mod tests {
|
||||
let cache = repo.join("org/projectlombok/lombok/1.18.48");
|
||||
fs::create_dir_all(&cache).unwrap();
|
||||
fs::write(cache.join("lombok-1.18.48.jar"), b"official agent").unwrap();
|
||||
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.3.jar");
|
||||
let patched = PathBuf::from("/managed/lombok-1.18.48-openidea.2.jar");
|
||||
let selected = lombok_agent(tmp.path(), None, Some(patched.clone()), Some(repo.clone()));
|
||||
assert_eq!(selected.jar(), Some(patched.as_path()));
|
||||
assert_eq!(selected.problem(), None);
|
||||
|
||||
@@ -1221,26 +1221,10 @@ impl Tools {
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<sources::Download> {
|
||||
match source {
|
||||
ReleaseSource::GitHub { repo } => match http.get_text(&sources::github_latest_url(repo), cancel) {
|
||||
Ok(json) => sources::github_pick(&json, assets, self.platform),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled => Err(e),
|
||||
// Without a token the API allows 60 requests an hour per IP address, often used
|
||||
// up behind a shared one (403/429); the release's web pages have no such limit.
|
||||
Err(api) => self.resolve_github_web(http, repo, assets, cancel).map_err(|web| {
|
||||
if web.kind == ErrorKind::Cancelled {
|
||||
return web;
|
||||
}
|
||||
tracing::warn!("GitHub release lookup for {repo} failed: {api}; web pages: {web}");
|
||||
IdeError::new(
|
||||
api.kind,
|
||||
format!(
|
||||
"{}\nDownload it from {} and set its Path in Settings › Languages & Frameworks › Language Servers.",
|
||||
api.message,
|
||||
sources::github_latest_page(repo)
|
||||
),
|
||||
)
|
||||
}),
|
||||
},
|
||||
ReleaseSource::GitHub { repo } => {
|
||||
let json = http.get_text(&sources::github_latest_url(repo), cancel)?;
|
||||
sources::github_pick(&json, assets, self.platform)
|
||||
}
|
||||
ReleaseSource::HashiCorp { product } => {
|
||||
let json = http.get_text(&sources::hashicorp_latest_url(product), cancel)?;
|
||||
sources::hashicorp_pick(&json, assets, self.platform)
|
||||
@@ -1267,27 +1251,6 @@ impl Tools {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`resolve_release`](Self::resolve_release) for a GitHub release, from its web pages.
|
||||
fn resolve_github_web(
|
||||
&self,
|
||||
http: &Http,
|
||||
repo: &str,
|
||||
assets: &[platform::AssetRule],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<sources::Download> {
|
||||
let page = http.effective_url(&sources::github_latest_page(repo), cancel)?;
|
||||
let tag = sources::github_tag_from_url(&page)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("{repo} has no published release")))?;
|
||||
let list = http.get_text(&sources::github_assets_page(repo, &tag), cancel)?;
|
||||
match sources::github_pick_web(repo, &tag, &list, None, assets, self.platform) {
|
||||
Err(e) if e.kind == ErrorKind::Unavailable => {
|
||||
let notes = http.get_text(&sources::github_tag_page(repo, &tag), cancel)?;
|
||||
sources::github_pick_web(repo, &tag, &list, Some(¬es), assets, self.platform)
|
||||
}
|
||||
result => result,
|
||||
}
|
||||
}
|
||||
|
||||
fn install_release(
|
||||
&self,
|
||||
spec: &ToolSpec,
|
||||
|
||||
@@ -263,15 +263,6 @@ fn curl_takes_revoke_best_effort(version: &str) -> bool {
|
||||
(major, minor) >= (7, 70) && first.split_whitespace().any(|w| w.eq_ignore_ascii_case("schannel"))
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Request {
|
||||
Get,
|
||||
/// Adoptium uses 404 as well as an empty array for absent platform builds.
|
||||
GetOrEmptyOn404,
|
||||
/// Headers, then the URL redirects ended at.
|
||||
Head,
|
||||
}
|
||||
|
||||
/// HTTP via the system `curl` (shipped with macOS, Windows 10+ and every
|
||||
/// mainstream Linux), so the IDE carries no TLS stack of its own.
|
||||
pub struct Http {
|
||||
@@ -345,39 +336,26 @@ impl Http {
|
||||
self.get_text_limited(url, None, cancel)
|
||||
}
|
||||
|
||||
/// Where `url` ends up after redirects (a HEAD request).
|
||||
pub fn effective_url(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
let out = self.get_text_response(url, None, Request::Head, cancel)?;
|
||||
Ok(out.trim_end().rsplit('\n').next().unwrap_or_default().trim().to_owned())
|
||||
}
|
||||
|
||||
/// GETs `url` as text, failing when the response is larger than `max_bytes`.
|
||||
pub fn get_text_limited(&self, url: &str, max_bytes: Option<u64>, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
self.get_text_response(url, max_bytes, Request::Get, cancel)
|
||||
self.get_text_response(url, max_bytes, false, cancel)
|
||||
}
|
||||
|
||||
/// Adoptium uses 404 as well as an empty array for absent platform builds.
|
||||
pub fn get_text_or_empty_on_404(&self, url: &str, cancel: &CancellationToken) -> IdeResult<String> {
|
||||
self.get_text_response(url, None, Request::GetOrEmptyOn404, cancel)
|
||||
self.get_text_response(url, None, true, cancel)
|
||||
}
|
||||
|
||||
fn get_text_response(
|
||||
&self,
|
||||
url: &str,
|
||||
max_bytes: Option<u64>,
|
||||
request: Request,
|
||||
empty_on_404: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<String> {
|
||||
let (mut cmd, token) = self.base(url, cancel)?;
|
||||
let empty_on_404 = request == Request::GetOrEmptyOn404;
|
||||
match request {
|
||||
Request::Get => {}
|
||||
Request::GetOrEmptyOn404 => {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
Request::Head => {
|
||||
cmd.args(["--head", "--write-out", "\n%{url_effective}"]);
|
||||
}
|
||||
if empty_on_404 {
|
||||
cmd.args(["--write-out", "\n%{http_code}"]);
|
||||
}
|
||||
if let Some(max) = max_bytes {
|
||||
cmd.arg("--max-filesize").arg(max.to_string());
|
||||
|
||||
@@ -646,13 +646,12 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
// Equinox removed getModuleClassLoader(boolean), breaking lombok.config lookup (lombok#4059);
|
||||
// ECJ 3.46.200 keeps the constructor call in a constructor's statements, which
|
||||
// @SneakyThrows / @NonNull must not wrap or precede (lombok#4089).
|
||||
// Builders on compact record constructors need the patch in java/lombok/patches.
|
||||
// Return to Maven Central once an official release includes all of these fixes.
|
||||
// Return to Maven Central once an official release includes both.
|
||||
// Source commit, build instructions and checksum: java/lombok/README.md.
|
||||
method: Method::Bundled {
|
||||
filename: "lombok-1.18.48-openidea.3.jar",
|
||||
version: "1.18.48-openidea.3",
|
||||
bytes: include_bytes!("../../../java/lombok/lombok-1.18.48-openidea.3.jar"),
|
||||
filename: "lombok-1.18.48-openidea.2.jar",
|
||||
version: "1.18.48-openidea.2",
|
||||
bytes: include_bytes!("../../../java/lombok/lombok-1.18.48-openidea.2.jar"),
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
|
||||
+11
-184
@@ -47,146 +47,38 @@ pub fn github_latest_url(repo: &str) -> String {
|
||||
format!("https://api.github.com/repos/{repo}/releases/latest")
|
||||
}
|
||||
|
||||
/// The latest release's web page; it redirects to `…/releases/tag/<tag>`. Web pages don't
|
||||
/// count against the API's rate limit for unauthenticated clients (60 requests an hour).
|
||||
pub fn github_latest_page(repo: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/latest")
|
||||
}
|
||||
|
||||
/// The tag `github_latest_page` redirected to.
|
||||
pub fn github_tag_from_url(url: &str) -> Option<String> {
|
||||
let (_, tag) = url.split_once("/releases/tag/")?;
|
||||
let tag = tag.split(['?', '#']).next().unwrap_or(tag).trim_end_matches('/');
|
||||
(!tag.is_empty()).then(|| percent_decode(tag))
|
||||
}
|
||||
|
||||
pub fn github_tag_page(repo: &str, tag: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/tag/{tag}")
|
||||
}
|
||||
|
||||
/// The asset list the release page loads (HTML).
|
||||
pub fn github_assets_page(repo: &str, tag: &str) -> String {
|
||||
format!("https://github.com/{repo}/releases/expanded_assets/{tag}")
|
||||
}
|
||||
|
||||
/// One file of a release, from the API or its web page.
|
||||
struct ReleaseAsset {
|
||||
name: String,
|
||||
url: String,
|
||||
size: Option<u64>,
|
||||
sha256: Option<String>,
|
||||
}
|
||||
|
||||
/// Picks the platform asset from a GitHub `releases/latest` response. Links
|
||||
/// in the release notes count as assets too (some projects host binaries on
|
||||
/// a CDN and only link them).
|
||||
pub fn github_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult<Download> {
|
||||
let rel: GhRelease = serde_json::from_str(json).map_err(|e| proto(format!("unexpected GitHub response: {e}")))?;
|
||||
let assets: Vec<ReleaseAsset> = rel
|
||||
.assets
|
||||
.into_iter()
|
||||
.map(|a| ReleaseAsset {
|
||||
sha256: a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase),
|
||||
name: a.name,
|
||||
url: a.browser_download_url,
|
||||
size: a.size,
|
||||
})
|
||||
.collect();
|
||||
pick_release(&rel.tag_name, &assets, rel.body.as_deref().unwrap_or(""), rules, platform)
|
||||
}
|
||||
|
||||
/// [`github_pick`] from the release's web pages: `assets_html` from [`github_assets_page`] and,
|
||||
/// for projects that only link their builds, `notes_html` from [`github_tag_page`].
|
||||
pub fn github_pick_web(
|
||||
repo: &str,
|
||||
tag: &str,
|
||||
assets_html: &str,
|
||||
notes_html: Option<&str>,
|
||||
rules: &[AssetRule],
|
||||
platform: Platform,
|
||||
) -> IdeResult<Download> {
|
||||
let prefix = format!("href=\"/{repo}/releases/download/");
|
||||
let mut assets: Vec<ReleaseAsset> = Vec::new();
|
||||
let mut rest = assets_html;
|
||||
while let Some(i) = rest.find(&prefix) {
|
||||
let tail = &rest[i + "href=\"".len()..];
|
||||
let Some(end) = tail.find('"') else { break };
|
||||
let path = html_unescape(&tail[..end]);
|
||||
rest = &tail[end..];
|
||||
let name = percent_decode(file_name(&path));
|
||||
if name.is_empty() || assets.iter().any(|a| a.name == name) {
|
||||
continue;
|
||||
}
|
||||
// `<clipboard-copy aria-label="Copy to clipboard digest for <name>" … value="sha256:<hex>">`
|
||||
let sha256 = assets_html.find(&format!("digest for {}\"", html_escape(&name))).and_then(|at| {
|
||||
let tag_end = assets_html[at..].find('>').map_or(assets_html.len(), |e| at + e);
|
||||
let value = assets_html[at..tag_end].split_once("value=\"sha256:")?.1;
|
||||
let hex: String = value.chars().take_while(char::is_ascii_hexdigit).collect();
|
||||
(hex.len() == 64).then(|| hex.to_ascii_lowercase())
|
||||
});
|
||||
assets.push(ReleaseAsset { url: format!("https://github.com{path}"), name, size: None, sha256 });
|
||||
}
|
||||
pick_release(tag, &assets, &html_unescape(notes_html.unwrap_or("")), rules, platform)
|
||||
}
|
||||
|
||||
fn pick_release(
|
||||
tag: &str,
|
||||
assets: &[ReleaseAsset],
|
||||
notes: &str,
|
||||
rules: &[AssetRule],
|
||||
platform: Platform,
|
||||
) -> IdeResult<Download> {
|
||||
let version = tag.trim_start_matches('v').to_string();
|
||||
let names: Vec<&str> = assets.iter().map(|a| a.name.as_str()).collect();
|
||||
let version = rel.tag_name.trim_start_matches('v').to_string();
|
||||
let names: Vec<&str> = rel.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
if let Some(name) = select_asset(&names, rules, platform) {
|
||||
let a = assets.iter().find(|a| a.name == name).expect("selected from list");
|
||||
let checksum_url = assets
|
||||
let a = rel.assets.iter().find(|a| a.name == name).expect("selected from list");
|
||||
let sha256 = a.digest.as_deref().and_then(|d| d.strip_prefix("sha256:")).map(str::to_ascii_lowercase);
|
||||
let checksum_url = rel
|
||||
.assets
|
||||
.iter()
|
||||
.find(|c| c.name == format!("{name}.sha256") || c.name == format!("{name}.sha256sum"))
|
||||
.map(|c| c.url.clone());
|
||||
.map(|c| c.browser_download_url.clone());
|
||||
return Ok(Download {
|
||||
version,
|
||||
name: a.name.clone(),
|
||||
url: a.url.clone(),
|
||||
url: a.browser_download_url.clone(),
|
||||
size: a.size,
|
||||
sha256: a.sha256.clone(),
|
||||
sha256,
|
||||
checksum_url,
|
||||
});
|
||||
}
|
||||
let links = extract_links(notes);
|
||||
let links = extract_links(rel.body.as_deref().unwrap_or(""));
|
||||
let link_names: Vec<&str> = links.iter().map(|u| file_name(u)).collect();
|
||||
if let Some(name) = select_asset(&link_names, rules, platform) {
|
||||
let url = links.iter().find(|u| file_name(u) == name).expect("selected from list").clone();
|
||||
let checksum_url = links.iter().find(|u| *u == &format!("{url}.sha256")).cloned();
|
||||
return Ok(Download { version, name: name.to_string(), url, size: None, sha256: None, checksum_url });
|
||||
}
|
||||
Err(IdeError::unavailable(format!("release {tag} has no build for this platform")))
|
||||
}
|
||||
|
||||
fn html_unescape(s: &str) -> String {
|
||||
s.replace(""", "\"").replace("'", "'").replace("<", "<").replace(">", ">").replace("&", "&")
|
||||
}
|
||||
|
||||
fn html_escape(s: &str) -> String {
|
||||
s.replace('&', "&").replace('"', """).replace('<', "<").replace('>', ">")
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let bytes = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& let Some(b) = s.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(h, 16).ok())
|
||||
{
|
||||
out.push(b);
|
||||
i += 3;
|
||||
} else {
|
||||
out.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
String::from_utf8(out).unwrap_or_else(|_| s.to_owned())
|
||||
Err(IdeError::unavailable(format!("release {} has no build for this platform", rel.tag_name)))
|
||||
}
|
||||
|
||||
fn file_name(url: &str) -> &str {
|
||||
@@ -444,71 +336,6 @@ mod tests {
|
||||
assert_eq!(d.name, "kotlin-lsp-0.253.10629-linux-x64.zip");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_tag_from_redirect() {
|
||||
let url = "https://github.com/Kotlin/kotlin-lsp/releases/tag/kotlin-lsp/v263.7084.0";
|
||||
assert_eq!(github_tag_from_url(url).as_deref(), Some("kotlin-lsp/v263.7084.0"));
|
||||
let url = "https://github.com/o/r/releases/tag/v1.0%2Bbuild";
|
||||
assert_eq!(github_tag_from_url(url).as_deref(), Some("v1.0+build"));
|
||||
// A repository without releases stays on the list.
|
||||
assert_eq!(github_tag_from_url("https://github.com/o/r/releases"), None);
|
||||
}
|
||||
|
||||
/// Trimmed from `github.com/<repo>/releases/expanded_assets/<tag>`.
|
||||
const ASSETS_HTML: &str = r#"<li><div><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz" rel="nofollow" data-turbo="false" class="wb-break-all">
|
||||
<span class="text-bold">lua-language-server-3.13.5-darwin-arm64.tar.gz</span></a></div>
|
||||
<span class="Truncate-text">sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789</span>
|
||||
<clipboard-copy id="clipboard-button-sha256:abc" aria-label="Copy to clipboard digest for lua-language-server-3.13.5-darwin-arm64.tar.gz" type="button" value="sha256:ABCDEF0123456789abcdef0123456789abcdef0123456789abcdef0123456789" class="Button"></clipboard-copy></li>
|
||||
<li><a href="/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-linux-x64.tar.gz" rel="nofollow">lua-language-server-3.13.5-linux-x64.tar.gz</a></li>
|
||||
<li><a href="/LuaLS/lua-language-server/archive/refs/tags/3.13.5.zip" rel="nofollow">Source code (zip)</a></li>"#;
|
||||
|
||||
#[test]
|
||||
fn github_web_assets_with_digest() {
|
||||
let rules = find("lua-language-server").unwrap().asset_rules();
|
||||
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, MAC_ARM).unwrap();
|
||||
assert_eq!(d.version, "3.13.5");
|
||||
assert_eq!(d.name, "lua-language-server-3.13.5-darwin-arm64.tar.gz");
|
||||
assert_eq!(
|
||||
d.url,
|
||||
"https://github.com/LuaLS/lua-language-server/releases/download/3.13.5/lua-language-server-3.13.5-darwin-arm64.tar.gz"
|
||||
);
|
||||
assert_eq!(d.sha256.as_deref(), Some("abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"));
|
||||
let d = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, LINUX).unwrap();
|
||||
assert_eq!(d.sha256, None);
|
||||
let win = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
let err = github_pick_web("LuaLS/lua-language-server", "3.13.5", ASSETS_HTML, None, rules, win).unwrap_err();
|
||||
assert_eq!(err.kind, ErrorKind::Unavailable);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn github_web_sibling_checksum_and_notes_links() {
|
||||
let html = r#"<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos" rel="nofollow">
|
||||
<a href="/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256" rel="nofollow">"#;
|
||||
let d = github_pick_web(
|
||||
"artempyanykh/marksman",
|
||||
"2024-12-18",
|
||||
html,
|
||||
None,
|
||||
find("marksman").unwrap().asset_rules(),
|
||||
MAC_ARM,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
d.checksum_url.as_deref(),
|
||||
Some("https://github.com/artempyanykh/marksman/releases/download/2024-12-18/marksman-macos.sha256")
|
||||
);
|
||||
|
||||
let notes = r#"<li><a href="https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2" rel="nofollow">macOS arm</a></li>"#;
|
||||
let rules = find("kotlin-lsp").unwrap().asset_rules();
|
||||
let d =
|
||||
github_pick_web("Kotlin/kotlin-lsp", "kotlin-lsp/v0.253.10629", "", Some(notes), rules, MAC_ARM).unwrap();
|
||||
assert_eq!(d.name, "kotlin-lsp-0.253.10629-mac-aarch64.zip");
|
||||
assert_eq!(
|
||||
d.url,
|
||||
"https://download-cdn.jetbrains.com/kotlin-lsp/0.253.10629/kotlin-lsp-0.253.10629-mac-aarch64.zip?a=1&b=2"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashicorp() {
|
||||
let json = r#"{"version":"0.36.4","url_shasums":"https://r/terraform-ls_0.36.4_SHA256SUMS","builds":[
|
||||
|
||||
@@ -58,20 +58,16 @@ fn bundled_lombok_replaces_legacy_installs_on_every_platform_without_downloads()
|
||||
fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
fs::write(&legacy, b"old official agent").unwrap();
|
||||
fs::write(env.root.join("lombok").join(MANIFEST), r#"{"version":"1.18.48"}"#).unwrap();
|
||||
let previous = env.root.join("lombok/bin/lombok-1.18.48-openidea.2.jar");
|
||||
fs::write(&previous, b"previous bundled agent").unwrap();
|
||||
let tools = Tools::with_paths(env.root.clone(), OsString::new(), Platform { os, arch });
|
||||
let info = tools.info("lombok", &[]).unwrap();
|
||||
let path = managed_bundle(env.root.parent().unwrap(), "lombok").unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.3"));
|
||||
assert_eq!(info.version.as_deref(), Some("1.18.48-openidea.2"));
|
||||
assert_eq!(info.path, Some(path.clone().into()));
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.3.jar");
|
||||
assert_eq!(path.file_name().unwrap(), "lombok-1.18.48-openidea.2.jar");
|
||||
assert!(!info.can_install && !info.can_uninstall);
|
||||
assert_eq!(tools.unavailable_reason(registry::find("lombok").unwrap()), None);
|
||||
assert_eq!(fs::read(&legacy).unwrap(), b"old official agent", "other windows may still use it");
|
||||
assert_ne!(path, previous, "new agent path must invalidate the JDT LS workspace");
|
||||
assert_eq!(fs::read(&previous).unwrap(), b"previous bundled agent", "other windows may still use it");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+39
-127
@@ -93,131 +93,6 @@ fn git_select(
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// The commits of a file's history and the path it had in each. The commits that changed
|
||||
/// the path, merges included (`--full-history --simplify-merges`); where it appears, a
|
||||
/// rename from any parent continues the history under the old path from that parent.
|
||||
/// Unlike `--follow`, this sees renames done in merges and history merged in from other
|
||||
/// branches.
|
||||
fn file_history(
|
||||
r: &Repo,
|
||||
scopes: &[String],
|
||||
rel: &str,
|
||||
first_parent: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<(HashSet<ObjectId>, HashMap<ObjectId, String>)> {
|
||||
let mut ids = HashSet::new();
|
||||
let mut paths = HashMap::new();
|
||||
let mut starts: std::collections::VecDeque<(Vec<String>, String)> =
|
||||
scopes.iter().map(|s| (s.split_whitespace().map(str::to_string).collect(), rel.to_string())).collect();
|
||||
let mut started = HashSet::new();
|
||||
while let Some((revs, path)) = starts.pop_front() {
|
||||
if !started.insert((revs.clone(), path.clone())) {
|
||||
continue;
|
||||
}
|
||||
let mut args: Vec<String> =
|
||||
["log", "--format=%x00%H", "--name-status", "-z", "--full-history", "--simplify-merges", "--no-follow"]
|
||||
.map(String::from)
|
||||
.into();
|
||||
if first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.push("--end-of-options".into());
|
||||
args.extend(revs);
|
||||
let pathspec = ["--".to_string(), format!(":(literal){path}")];
|
||||
let run = |args: &[String]| -> IdeResult<Vec<(ObjectId, Option<char>)>> {
|
||||
let argv: Vec<&str> = args.iter().chain(&pathspec).map(String::as_str).collect();
|
||||
Ok(parse_name_status_log(&crate::cli::run_cancellable(r.root(), &argv, cancel)?.stdout_str()))
|
||||
};
|
||||
let records = run(&args)?;
|
||||
// Where the file appears its history ends: what is behind that is another file of
|
||||
// the same name (a rename continues the history below). The commits of the history
|
||||
// are those not behind a creation; the statuses come from the full listing (with
|
||||
// its parents excluded a merge shows no changes).
|
||||
let created: Vec<ObjectId> = records.iter().filter(|(_, s)| *s == Some('A')).map(|(id, _)| *id).collect();
|
||||
let kept: Option<HashSet<ObjectId>> = if created.is_empty() {
|
||||
None
|
||||
} else {
|
||||
args.extend(created.iter().map(|id| format!("^{id}^@")));
|
||||
Some(run(&args)?.into_iter().map(|(id, _)| id).collect())
|
||||
};
|
||||
let mut created = Vec::new();
|
||||
for (id, first_status) in records {
|
||||
if kept.as_ref().is_some_and(|k| !k.contains(&id)) || !ids.insert(id) {
|
||||
continue;
|
||||
}
|
||||
paths.insert(id, path.clone());
|
||||
if first_status == Some('A') {
|
||||
created.push(id);
|
||||
}
|
||||
}
|
||||
for commit in created {
|
||||
let line = crate::cli::run_cancellable(
|
||||
r.root(),
|
||||
&["rev-list", "--parents", "-n", "1", "--end-of-options", &commit.to_string()],
|
||||
cancel,
|
||||
)?
|
||||
.stdout_str();
|
||||
let parents: Vec<&str> =
|
||||
line.split_whitespace().skip(1).take(if first_parent { 1 } else { usize::MAX }).collect();
|
||||
for parent in parents {
|
||||
if let Some(old) = renamed_from(r, parent, &commit.to_string(), &path, cancel)? {
|
||||
starts.push_back((vec![parent.to_string()], old));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((ids, paths))
|
||||
}
|
||||
|
||||
/// `git log --format=%x00%H --name-status -z`: each commit with the status letter of its
|
||||
/// first change (none for a merge listed without changes).
|
||||
fn parse_name_status_log(text: &str) -> Vec<(ObjectId, Option<char>)> {
|
||||
let mut out: Vec<(ObjectId, Option<char>)> = Vec::new();
|
||||
let mut tokens = text.split('\0');
|
||||
while let Some(token) = tokens.next() {
|
||||
let token = token.trim_start_matches('\n');
|
||||
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
|
||||
out.push((id, None));
|
||||
continue;
|
||||
}
|
||||
let Some(status) = token.chars().next().filter(|c| c.is_ascii_uppercase()) else { continue };
|
||||
if !token[1..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
continue;
|
||||
}
|
||||
let paths = if matches!(status, 'R' | 'C') { 2 } else { 1 };
|
||||
for _ in 0..paths {
|
||||
tokens.next();
|
||||
}
|
||||
if let Some(last) = out.last_mut() {
|
||||
last.1.get_or_insert(status);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The path `path` had in `parent` when `commit` renamed it.
|
||||
fn renamed_from(
|
||||
r: &Repo,
|
||||
parent: &str,
|
||||
commit: &str,
|
||||
path: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let args = ["diff", "-M", "--name-status", "-z", "--diff-filter=R", "--no-ext-diff", parent, commit, "--"];
|
||||
let text = crate::cli::run_cancellable(r.root(), &args, cancel)?.stdout_str();
|
||||
let mut tokens = text.split('\0');
|
||||
while let Some(status) = tokens.next() {
|
||||
if !status.starts_with('R') {
|
||||
continue;
|
||||
}
|
||||
let (Some(old), Some(new)) = (tokens.next(), tokens.next()) else { break };
|
||||
if new == path {
|
||||
return Ok(Some(old.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
@@ -355,8 +230,45 @@ fn log_indexed(
|
||||
let path_ids = if paths.is_empty() {
|
||||
None
|
||||
} else if q.follow && paths.len() == 1 {
|
||||
let (ids, at) = file_history(r, &scopes, &paths[0], q.first_parent, cancel)?;
|
||||
history_paths = at;
|
||||
let mut ids = HashSet::new();
|
||||
let rel = &paths[0];
|
||||
let mut args = vec![
|
||||
"log".to_string(),
|
||||
"--format=%x00%H".into(),
|
||||
"--name-status".into(),
|
||||
"-z".into(),
|
||||
"--follow".into(),
|
||||
"--topo-order".into(),
|
||||
];
|
||||
if q.first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.push("--end-of-options".into());
|
||||
for scope in &scopes {
|
||||
let mut args = args.clone();
|
||||
args.extend(scope.split_whitespace().map(str::to_string));
|
||||
args.extend(["--".into(), rel.clone()]);
|
||||
let output =
|
||||
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
|
||||
let text = output.stdout_str();
|
||||
let mut tokens = text.split('\0').peekable();
|
||||
let mut current_path = rel.clone();
|
||||
while let Some(token) = tokens.next() {
|
||||
let token = token.trim_start_matches('\n');
|
||||
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
|
||||
ids.insert(id);
|
||||
history_paths.insert(id, current_path.clone());
|
||||
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
|
||||
&& new == current_path
|
||||
{
|
||||
current_path = old.to_string();
|
||||
}
|
||||
} else if ["A", "D", "M", "T", "U"].contains(&token) {
|
||||
tokens.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
} else {
|
||||
Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?)
|
||||
|
||||
+8
-137
@@ -273,12 +273,6 @@ pub struct CommitOptions {
|
||||
/// entries of the index change; afterwards their index entries match the new commit.
|
||||
#[serde(default)]
|
||||
pub partial: Vec<PartialCommitFile>,
|
||||
/// Files committed with their working-tree content, staged or not.
|
||||
#[serde(default)]
|
||||
pub stage: Vec<ide_core::IpcPath>,
|
||||
/// Staged files left out of the commit; they stay staged afterwards.
|
||||
#[serde(default)]
|
||||
pub exclude: Vec<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// A file of a partial commit: which of its changes against HEAD to commit.
|
||||
@@ -362,27 +356,6 @@ pub struct PushBranchOptions<'a> {
|
||||
pub remote: Option<&'a str>,
|
||||
/// `git push --force-with-lease`.
|
||||
pub force_with_lease: bool,
|
||||
/// `git push --force` (Force push anyway after a rejected force-with-lease push).
|
||||
pub force: bool,
|
||||
/// Record the target as the branch's upstream even when it already has one.
|
||||
pub set_upstream: bool,
|
||||
/// Skip the pre-push hook (`--no-verify`).
|
||||
pub no_verify: bool,
|
||||
}
|
||||
|
||||
/// Options of [`Repo::push_with`]: the Push dialog's choices for the current branch.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct PushOptions {
|
||||
/// `git push --force-with-lease`.
|
||||
pub force_with_lease: bool,
|
||||
/// `git push --force` (Force push anyway after a rejected force-with-lease push).
|
||||
pub force: bool,
|
||||
/// Push to the branch's remote under the same name and record it as upstream.
|
||||
pub set_upstream: bool,
|
||||
/// Push tags too.
|
||||
pub tags: Option<PushTags>,
|
||||
/// Skip the pre-push hook (`--no-verify`).
|
||||
pub no_verify: bool,
|
||||
}
|
||||
|
||||
/// What an interactive rebase does with one commit.
|
||||
@@ -507,10 +480,6 @@ pub enum CheckoutOutcome {
|
||||
LocalChanges {
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// These untracked files (repository-relative paths) would be overwritten.
|
||||
UntrackedFiles {
|
||||
files: Vec<String>,
|
||||
},
|
||||
/// Local branch `local` has commits that remote branch `remote` does not.
|
||||
LocalBranchAhead {
|
||||
local: String,
|
||||
@@ -523,14 +492,6 @@ pub enum CheckoutOutcome {
|
||||
},
|
||||
}
|
||||
|
||||
/// HEAD before and after a history rewrite, for its Undo.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HeadChange {
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
/// A deleted local branch, enough to restore it (IDEA's "Restore" notification action).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -544,15 +505,6 @@ pub struct DeletedBranch {
|
||||
pub unmerged: bool,
|
||||
}
|
||||
|
||||
/// A deleted tag, enough to restore it (Restore in the tag-deleted notification).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeletedTag {
|
||||
pub name: String,
|
||||
/// What the tag pointed at: its tag object (annotated tags) or commit.
|
||||
pub target: String,
|
||||
}
|
||||
|
||||
/// Who stands on each side of the conflicts of the operation in progress (merge editor
|
||||
/// titles, as IDEA labels them).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -623,8 +575,6 @@ mod log_index;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
mod preserve;
|
||||
pub use ops::UNSTAGED_CHANGES_PREVENT_REBASE;
|
||||
mod refs;
|
||||
mod remotes;
|
||||
mod repo;
|
||||
@@ -1056,7 +1006,7 @@ impl Repo {
|
||||
/// `amend` keeps the previous message.
|
||||
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, ..Default::default() };
|
||||
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
|
||||
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into()))
|
||||
}
|
||||
/// Commits with the Commit Options popup's choices.
|
||||
@@ -1098,17 +1048,6 @@ impl Repo {
|
||||
write::undo_commit(self, commit)
|
||||
}
|
||||
/// Deletes a local branch (`git branch -d`, `-D` when `force`).
|
||||
/// Edit Commit Message without touching the index or the working tree; returns HEAD
|
||||
/// before and after.
|
||||
pub fn reword(&self, commit: &str, message: &str) -> IdeResult<HeadChange> {
|
||||
write::reword(self, commit, message).map(|(before, after)| HeadChange { before, after })
|
||||
}
|
||||
|
||||
/// Undoes a history rewrite: HEAD goes from `after` back to `before`, local changes kept.
|
||||
pub fn restore_head(&self, before: &str, after: &str) -> IdeResult<()> {
|
||||
write::restore_head(self, before, after)
|
||||
}
|
||||
|
||||
pub fn delete_branch(&self, branch: &str, force: bool) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::delete_branch(self, branch, force)
|
||||
@@ -1185,11 +1124,9 @@ impl Repo {
|
||||
pub fn pull(&self, rebase: bool, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let mode: &[&str] = if rebase { &["--rebase"] } else { &["--no-rebase", "--ff"] };
|
||||
let mut args = vec!["pull", "--progress"];
|
||||
let mut args = vec!["pull", "--progress", "--autostash"];
|
||||
args.extend_from_slice(mode);
|
||||
preserve::around(self, "Local changes saved before update", || {
|
||||
cli::run_streaming(&self.root, &args, cancel, on_output)
|
||||
})
|
||||
cli::run_streaming(&self.root, &args, cancel, on_output)
|
||||
}
|
||||
/// Pushes the current branch. `set_upstream` pushes to the branch's remote (or
|
||||
/// `origin`) under the same name and records it as upstream.
|
||||
@@ -1200,7 +1137,8 @@ impl Repo {
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
self.push_with(PushOptions { force_with_lease, set_upstream, ..Default::default() }, cancel, on_output)
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push(self, force_with_lease, set_upstream, None, cancel, on_output)
|
||||
}
|
||||
/// [`Repo::push`] that also pushes tags (Push dialog › Push tags).
|
||||
pub fn push_with_tags(
|
||||
@@ -1210,23 +1148,9 @@ impl Repo {
|
||||
tags: Option<PushTags>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
self.push_with(PushOptions { force_with_lease, set_upstream, tags, ..Default::default() }, cancel, on_output)
|
||||
}
|
||||
/// Pushes the current branch with the Push dialog's options.
|
||||
pub fn push_with(
|
||||
&self,
|
||||
options: PushOptions,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push(self, options, cancel, on_output)
|
||||
}
|
||||
/// Whether the repository has an executable hook `name` (e.g. `pre-push`), wherever
|
||||
/// `core.hooksPath` puts it: the Push dialog then offers to skip it.
|
||||
pub fn has_hook(&self, name: &str) -> IdeResult<bool> {
|
||||
write::has_hook(self, name)
|
||||
write::push(self, force_with_lease, set_upstream, tags, cancel, on_output)
|
||||
}
|
||||
/// Pushes a local branch, or its history up to `revision`, to its upstream (Git Log
|
||||
/// › Push…, Push All up to Here).
|
||||
@@ -1260,12 +1184,6 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::push_branch(self, branch, options, cancel, on_output)
|
||||
}
|
||||
/// Update Project › Reset to the Remote Branch: the current branch is moved to its
|
||||
/// freshly fetched tracked branch; local commits are dropped, local changes kept.
|
||||
pub fn reset_to_upstream(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::reset_to_upstream(self, cancel, on_output)
|
||||
}
|
||||
/// Fast-forwards a local branch that is not checked out to its upstream (Branches ›
|
||||
/// Update). Fails when the branch has diverged.
|
||||
pub fn update_branch(&self, branch: &str, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
@@ -1278,12 +1196,7 @@ impl Repo {
|
||||
/// conflicts are unresolved.
|
||||
pub fn continue_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::continue_op(self, false, cancel, on_output)
|
||||
}
|
||||
/// Stage and Retry: stages the changes of tracked files, then continues.
|
||||
pub fn stage_and_continue(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::continue_op(self, true, cancel, on_output)
|
||||
ops::continue_op(self, cancel, on_output)
|
||||
}
|
||||
pub fn abort_operation(&self, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
@@ -1300,19 +1213,9 @@ impl Repo {
|
||||
commits: &[String],
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
self.cherry_pick_with(commits, false, cancel, on_output)
|
||||
}
|
||||
/// [`Repo::cherry_pick`]; `record_origin` adds "(cherry picked from commit …)" (`-x`).
|
||||
pub fn cherry_pick_with(
|
||||
&self,
|
||||
commits: &[String],
|
||||
record_origin: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::cherry_pick(self, commits, record_origin, cancel, on_output)
|
||||
ops::cherry_pick(self, commits, cancel, on_output)
|
||||
}
|
||||
/// Reverts `commits` in order (newest first) with git's default messages.
|
||||
pub fn revert(&self, commits: &[String], cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
@@ -1348,38 +1251,6 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::create_tag(self, name, commit, message, force)
|
||||
}
|
||||
/// Deletes a local tag, returning what [`Repo::restore_tag`] needs to undo it.
|
||||
pub fn delete_tag(&self, name: &str) -> IdeResult<DeletedTag> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::delete_tag(self, name)
|
||||
}
|
||||
/// Recreates a deleted tag (annotated ones with their message).
|
||||
pub fn restore_tag(&self, deleted: &DeletedTag) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::restore_tag(self, deleted)
|
||||
}
|
||||
/// Pushes one tag to `remote` (Push Tag › Push to …).
|
||||
pub fn push_tag(
|
||||
&self,
|
||||
tag: &str,
|
||||
remote: &str,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::push_tag(self, tag, remote, false, cancel, on_output)
|
||||
}
|
||||
/// Deletes a tag on `remote` (Delete on Remote, after deleting it locally).
|
||||
pub fn delete_remote_tag(
|
||||
&self,
|
||||
tag: &str,
|
||||
remote: &str,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::push_tag(self, tag, remote, true, cancel, on_output)
|
||||
}
|
||||
pub fn tags(&self) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
ops::tags(self)
|
||||
|
||||
@@ -106,7 +106,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult<Vec
|
||||
if branches.is_empty() {
|
||||
return Err(IdeError::invalid("no branch to merge"));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["merge".into()];
|
||||
let mut args: Vec<String> = vec!["merge".into(), "--autostash".into()];
|
||||
args.extend(merge_flags(o)?.into_iter().map(String::from));
|
||||
match o.message.as_deref().map(str::trim).filter(|m| !m.is_empty()) {
|
||||
Some(m) => args.extend(["-m".into(), m.to_string()]),
|
||||
@@ -120,7 +120,7 @@ pub(crate) fn merge_args(branches: &[String], o: &MergeOptions) -> IdeResult<Vec
|
||||
|
||||
/// `git rebase` arguments for the Rebase dialog.
|
||||
pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult<Vec<String>> {
|
||||
let mut args: Vec<String> = vec!["rebase".into()];
|
||||
let mut args: Vec<String> = vec!["rebase".into(), "--autostash".into()];
|
||||
if o.rebase_merges {
|
||||
args.push("--rebase-merges".into());
|
||||
}
|
||||
@@ -137,7 +137,7 @@ pub(crate) fn rebase_args(upstream: &str, o: &RebaseOptions) -> IdeResult<Vec<St
|
||||
/// `git pull` arguments for the Pull dialog: `remote` and its `branches` (none = the
|
||||
/// upstream's configured merge branch).
|
||||
pub(crate) fn pull_args(remote: &str, branches: &[String], rebase: bool, o: &MergeOptions) -> IdeResult<Vec<String>> {
|
||||
let mut args: Vec<String> = vec!["pull".into(), "--progress".into()];
|
||||
let mut args: Vec<String> = vec!["pull".into(), "--progress".into(), "--autostash".into()];
|
||||
if rebase {
|
||||
if o.no_ff || o.ff_only || o.squash || o.no_commit {
|
||||
return Err(IdeError::invalid("--rebase cannot be combined with merge options"));
|
||||
@@ -213,7 +213,7 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = merge_args(branches, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
crate::preserve::around(self, "Local changes saved before merge", || run(self.root(), &args, None).map(|_| ()))
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Rebases the current branch onto `upstream` (or `upstream..HEAD` onto `options.onto`).
|
||||
@@ -221,7 +221,7 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = rebase_args(upstream, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
crate::preserve::around(self, "Local changes saved before rebase", || run(self.root(), &args, None).map(|_| ()))
|
||||
run(self.root(), &args, None).map(|_| ())
|
||||
}
|
||||
|
||||
/// Git › Pull…: pulls `branches` of `remote` into the current branch.
|
||||
@@ -237,9 +237,7 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let args = pull_args(remote, branches, rebase, options)?;
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
crate::preserve::around(self, "Local changes saved before update", || {
|
||||
cli::run_streaming(self.root(), &args, cancel, on_output)
|
||||
})
|
||||
cli::run_streaming(self.root(), &args, cancel, on_output)
|
||||
}
|
||||
|
||||
/// Manage Remotes: adds, removes or edits a remote.
|
||||
@@ -307,10 +305,13 @@ mod tests {
|
||||
#[test]
|
||||
fn merge_args_follow_the_options() {
|
||||
let o = MergeOptions { no_ff: true, message: Some("Merge it".into()), ..Default::default() };
|
||||
assert_eq!(merge_args(&["dev".into()], &o).unwrap(), ["merge", "--no-ff", "-m", "Merge it", "dev"]);
|
||||
assert_eq!(
|
||||
merge_args(&["dev".into()], &o).unwrap(),
|
||||
["merge", "--autostash", "--no-ff", "-m", "Merge it", "dev"]
|
||||
);
|
||||
assert_eq!(
|
||||
merge_args(&["a".into(), "b".into()], &MergeOptions::default()).unwrap(),
|
||||
["merge", "--no-edit", "a", "b"]
|
||||
["merge", "--autostash", "--no-edit", "a", "b"]
|
||||
);
|
||||
let bad = MergeOptions { ff_only: true, squash: true, ..Default::default() };
|
||||
assert!(merge_args(&["dev".into()], &bad).is_err());
|
||||
@@ -321,15 +322,18 @@ mod tests {
|
||||
#[test]
|
||||
fn rebase_and_pull_args() {
|
||||
let o = RebaseOptions { onto: Some("main".into()), rebase_merges: true, keep_empty: false };
|
||||
assert_eq!(rebase_args("feature~3", &o).unwrap(), ["rebase", "--rebase-merges", "--onto", "main", "feature~3"]);
|
||||
assert_eq!(
|
||||
rebase_args("feature~3", &o).unwrap(),
|
||||
["rebase", "--autostash", "--rebase-merges", "--onto", "main", "feature~3"]
|
||||
);
|
||||
assert_eq!(
|
||||
pull_args("origin", &["main".into()], false, &MergeOptions { ff_only: true, ..Default::default() })
|
||||
.unwrap(),
|
||||
["pull", "--progress", "--no-rebase", "--ff-only", "origin", "main"]
|
||||
["pull", "--progress", "--autostash", "--no-rebase", "--ff-only", "origin", "main"]
|
||||
);
|
||||
assert_eq!(
|
||||
pull_args("origin", &[], true, &MergeOptions::default()).unwrap(),
|
||||
["pull", "--progress", "--rebase", "origin"]
|
||||
["pull", "--progress", "--autostash", "--rebase", "origin"]
|
||||
);
|
||||
assert!(pull_args("origin", &[], true, &MergeOptions { squash: true, ..Default::default() }).is_err());
|
||||
}
|
||||
|
||||
+14
-143
@@ -5,7 +5,6 @@ use crate::cli::{self, run};
|
||||
use crate::repo::{gx, resolve};
|
||||
use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// The in-progress operation git can continue / abort / skip.
|
||||
fn sequencer(r: &Repo) -> IdeResult<&'static str> {
|
||||
@@ -58,8 +57,7 @@ pub(crate) fn merge_sides(r: &Repo) -> IdeResult<MergeSides> {
|
||||
Some("cherry-pick") => sides.commit = read("CHERRY_PICK_HEAD").map(short),
|
||||
Some("revert") => sides.commit = read("REVERT_HEAD").map(short),
|
||||
// Conflicts outside an operation come from restoring stashed changes (Smart
|
||||
// Checkout, local changes put aside for a merge or update): stage 2 is the new HEAD,
|
||||
// stage 3 the user's.
|
||||
// Checkout, update with autostash): stage 2 is the new HEAD, stage 3 the user's.
|
||||
None if !crate::write::unmerged_paths(r)?.is_empty() => {
|
||||
sides.operation = Some("unstash".into());
|
||||
sides.reversed = true;
|
||||
@@ -115,38 +113,13 @@ fn check_commits(commits: &[String]) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Why a rebase can't continue: tracked files changed while it was stopped, not staged.
|
||||
pub const UNSTAGED_CHANGES_PREVENT_REBASE: &str =
|
||||
"There are unstaged changes in tracked files preventing rebase from continuing";
|
||||
|
||||
fn changed_paths(r: &Repo, args: &[&str]) -> IdeResult<Vec<String>> {
|
||||
let text = run(r.root(), args, None)?.stdout_str();
|
||||
Ok(text.split('\0').filter(|p| !p.is_empty()).map(str::to_string).collect())
|
||||
}
|
||||
|
||||
/// Concludes the operation in progress once every conflict is resolved (staged).
|
||||
/// `stage` first stages the changes of tracked files (Stage and Retry). Without it a
|
||||
/// rebase stages only edits of files already staged (made while resolving them) and
|
||||
/// otherwise fails with [`UNSTAGED_CHANGES_PREVENT_REBASE`].
|
||||
pub(crate) fn continue_op(r: &Repo, stage: bool, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> {
|
||||
pub(crate) fn continue_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let op = sequencer(r)?;
|
||||
let unresolved = unmerged_paths(r)?;
|
||||
if !unresolved.is_empty() {
|
||||
return Err(IdeError::invalid(format!("resolve all conflicts before continuing: {}", unresolved.join(", "))));
|
||||
}
|
||||
if stage {
|
||||
run(r.root(), &["add", "-u"], None)?;
|
||||
} else if op == "rebase" {
|
||||
let unstaged = changed_paths(r, &["diff", "--name-only", "-z", "--no-ext-diff"])?;
|
||||
if !unstaged.is_empty() {
|
||||
let staged: HashSet<String> =
|
||||
changed_paths(r, &["diff", "--cached", "--name-only", "-z", "--no-ext-diff"])?.into_iter().collect();
|
||||
if !unstaged.iter().all(|p| staged.contains(p)) {
|
||||
return Err(IdeError::invalid(UNSTAGED_CHANGES_PREVENT_REBASE));
|
||||
}
|
||||
cli::run_paths(r.root(), &["add", "-u"], &unstaged)?;
|
||||
}
|
||||
}
|
||||
let args: &[&str] = match op {
|
||||
// `git merge --continue` commits with the prepared MERGE_MSG.
|
||||
"merge" => &["merge", "--continue"],
|
||||
@@ -155,16 +128,12 @@ pub(crate) fn continue_op(r: &Repo, stage: bool, cancel: &CancellationToken, out
|
||||
"revert" => &["revert", "--continue"],
|
||||
_ => &["am", "--continue"],
|
||||
};
|
||||
let result = cli::run_streaming(r.root(), args, cancel, out);
|
||||
let finished = finish_operation(r);
|
||||
result.and(finished)
|
||||
cli::run_streaming(r.root(), args, cancel, out)
|
||||
}
|
||||
|
||||
pub(crate) fn abort_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let op = sequencer(r)?;
|
||||
let result = cli::run_streaming(r.root(), &[op, "--abort"], cancel, out);
|
||||
let finished = finish_operation(r);
|
||||
result.and(finished)
|
||||
cli::run_streaming(r.root(), &[op, "--abort"], cancel, out)
|
||||
}
|
||||
|
||||
pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)) -> IdeResult<()> {
|
||||
@@ -172,9 +141,7 @@ pub(crate) fn skip_op(r: &Repo, cancel: &CancellationToken, out: &dyn Fn(String)
|
||||
if op == "merge" {
|
||||
return Err(IdeError::invalid("a merge cannot be skipped; abort it instead"));
|
||||
}
|
||||
let result = cli::run_streaming(r.root(), &[op, "--skip"], cancel, out);
|
||||
let finished = finish_operation(r);
|
||||
result.and(finished)
|
||||
cli::run_streaming(r.root(), &[op, "--skip"], cancel, out)
|
||||
}
|
||||
|
||||
/// Merge commits need a mainline (`-m`), which IDEA doesn't offer for cherry-pick / revert.
|
||||
@@ -190,23 +157,18 @@ fn reject_merges(r: &Repo, commits: &[String], verb: &str) -> IdeResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies `commits` on top of HEAD, in the given order (pass oldest first);
|
||||
/// `record_origin` adds "(cherry picked from commit …)" to their messages.
|
||||
/// Applies `commits` on top of HEAD, in the given order (pass oldest first).
|
||||
pub(crate) fn cherry_pick(
|
||||
r: &Repo,
|
||||
commits: &[String],
|
||||
record_origin: bool,
|
||||
cancel: &CancellationToken,
|
||||
out: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
check_commits(commits)?;
|
||||
reject_merges(r, commits, "cherry-pick")?;
|
||||
let mut args = vec!["cherry-pick"];
|
||||
if record_origin {
|
||||
args.push("-x");
|
||||
}
|
||||
args.extend(commits.iter().map(String::as_str));
|
||||
apply_skipping_empty(r, "cherry-pick", &args, cancel, out)
|
||||
cli::run_streaming(r.root(), &args, cancel, out)
|
||||
}
|
||||
|
||||
/// Creates commits undoing `commits`, in the given order (pass newest first).
|
||||
@@ -215,42 +177,7 @@ pub(crate) fn revert(r: &Repo, commits: &[String], cancel: &CancellationToken, o
|
||||
reject_merges(r, commits, "revert")?;
|
||||
let mut args = vec!["revert", "--no-edit"];
|
||||
args.extend(commits.iter().map(String::as_str));
|
||||
apply_skipping_empty(r, "revert", &args, cancel, out)
|
||||
}
|
||||
|
||||
/// Runs a cherry-pick / revert sequence. A commit whose changes are already there leaves
|
||||
/// nothing to commit: it is skipped and the rest of the sequence goes on.
|
||||
fn apply_skipping_empty(
|
||||
r: &Repo,
|
||||
verb: &str,
|
||||
args: &[&str],
|
||||
cancel: &CancellationToken,
|
||||
out: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let head_file = if verb == "revert" { "REVERT_HEAD" } else { "CHERRY_PICK_HEAD" };
|
||||
let said_nothing = std::cell::Cell::new(false);
|
||||
let watch = |line: String| {
|
||||
// git runs with LC_ALL=C; a single commit with nothing left to apply stops here.
|
||||
said_nothing.set(said_nothing.get() || line.contains("nothing to commit"));
|
||||
out(line)
|
||||
};
|
||||
let mut result = cli::run_streaming(r.root(), args, cancel, &watch);
|
||||
if result.is_err() && said_nothing.get() && r.branch_info()?.operation.is_none() {
|
||||
out("Skipped: its changes are already applied".into());
|
||||
return Ok(());
|
||||
}
|
||||
while result.is_err() && !cancel.is_cancelled() {
|
||||
let Ok(stopped) = std::fs::read_to_string(r.git_dir.join(head_file)) else { break };
|
||||
let empty = unmerged_paths(r)?.is_empty()
|
||||
&& cli::run_raw(r.root(), &["diff", "--cached", "--quiet", "HEAD", "--"], None)?.code == Some(0);
|
||||
if !empty {
|
||||
break;
|
||||
}
|
||||
let id = stopped.trim();
|
||||
out(format!("{} skipped: its changes are already applied", &id[..id.len().min(8)]));
|
||||
result = cli::run_streaming(r.root(), &[verb, "--skip"], cancel, out);
|
||||
}
|
||||
result
|
||||
cli::run_streaming(r.root(), &args, cancel, out)
|
||||
}
|
||||
|
||||
pub(crate) fn reset(
|
||||
@@ -354,7 +281,7 @@ pub(crate) fn interactive_rebase(
|
||||
let file = work.join(format!("msg-{i}"));
|
||||
std::fs::write(&file, m)?;
|
||||
todo.push_str(&format!(
|
||||
"exec git commit --amend --allow-empty --no-verify --quiet --cleanup=whitespace -F {}\n",
|
||||
"exec git commit --amend --allow-empty --no-verify --quiet --cleanup=strip -F {}\n",
|
||||
quote(&file)
|
||||
));
|
||||
}
|
||||
@@ -362,26 +289,15 @@ pub(crate) fn interactive_rebase(
|
||||
let todo_file = work.join("todo");
|
||||
std::fs::write(&todo_file, todo)?;
|
||||
let editor = format!("cp {}", quote(&todo_file));
|
||||
// Local changes are put aside and restored when the rebase is over, staged ones as
|
||||
// staged (git's --autostash would bring them all back unstaged).
|
||||
crate::preserve::save(r, "Local changes saved before rebase")?;
|
||||
let mut args = vec!["rebase", "--interactive", "--no-autosquash"];
|
||||
let mut args = vec!["rebase", "--interactive", "--autostash", "--no-autosquash"];
|
||||
args.push(base.unwrap_or("--root"));
|
||||
let env = [("GIT_SEQUENCE_EDITOR", std::ffi::OsStr::new(&editor))];
|
||||
let result = cli::run_streaming_env(r.root(), &args, &env, cancel, out);
|
||||
cli::run_streaming_env(r.root(), &args, &env, cancel, out)?;
|
||||
// A stopped rebase still needs the message files for its remaining `exec` lines.
|
||||
let finished = finish_operation(r);
|
||||
result.and(finished)
|
||||
}
|
||||
|
||||
/// Once a rebase / merge / cherry-pick / revert is over, brings back the local changes
|
||||
/// put aside for it; the plan files of [`interactive_rebase`] go once it is done.
|
||||
pub(crate) fn finish_operation(r: &Repo) -> IdeResult<()> {
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Ok(());
|
||||
if r.branch_info()?.operation.is_none() {
|
||||
let _ = std::fs::remove_dir_all(&work);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(r.git_dir.join("openidea-rebase"));
|
||||
crate::preserve::restore(r)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Creates a tag on `commit`: annotated when `message` is non-empty, else lightweight.
|
||||
@@ -409,51 +325,6 @@ pub(crate) fn create_tag(r: &Repo, name: &str, commit: &str, message: Option<&st
|
||||
run(r.root(), &args, message.map(str::as_bytes)).map(|_| ())
|
||||
}
|
||||
|
||||
fn tag_ref(name: &str) -> IdeResult<String> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() || name.starts_with('-') || name.contains("..") || name.contains(':') {
|
||||
return Err(IdeError::invalid(format!("'{name}' is not a valid tag name")));
|
||||
}
|
||||
Ok(format!("refs/tags/{name}"))
|
||||
}
|
||||
|
||||
pub(crate) fn delete_tag(r: &Repo, name: &str) -> IdeResult<crate::DeletedTag> {
|
||||
let tag = tag_ref(name)?;
|
||||
let target = run(r.root(), &["rev-parse", "-q", "--verify", &tag], None)
|
||||
.map_err(|_| IdeError::invalid(format!("Could not find tag {name}")))?
|
||||
.stdout_str();
|
||||
let target = target.trim().to_string();
|
||||
run(r.root(), &["update-ref", "-d", &tag, &target], None)?;
|
||||
Ok(crate::DeletedTag { name: name.trim().to_string(), target })
|
||||
}
|
||||
|
||||
pub(crate) fn restore_tag(r: &Repo, deleted: &crate::DeletedTag) -> IdeResult<()> {
|
||||
let tag = tag_ref(&deleted.name)?;
|
||||
let target = deleted.target.trim();
|
||||
if target.is_empty() || target.starts_with('-') {
|
||||
return Err(IdeError::invalid("invalid tag target"));
|
||||
}
|
||||
// Only recreates it: a tag made with that name meanwhile stays.
|
||||
run(r.root(), &["update-ref", &tag, target, ""], None).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn push_tag(
|
||||
r: &Repo,
|
||||
name: &str,
|
||||
remote: &str,
|
||||
delete: bool,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let tag = tag_ref(name)?;
|
||||
let remote = remote.trim();
|
||||
if remote.is_empty() || remote.starts_with('-') {
|
||||
return Err(IdeError::invalid(format!("invalid remote '{remote}'")));
|
||||
}
|
||||
let spec = if delete { format!(":{tag}") } else { format!("{tag}:{tag}") };
|
||||
cli::run_streaming(r.root(), &["push", "--progress", remote, &spec], cancel, on_output)
|
||||
}
|
||||
|
||||
/// Tag names, sorted.
|
||||
pub(crate) fn tags(r: &Repo) -> IdeResult<Vec<String>> {
|
||||
let repo = r.gix()?;
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
//! Local changes put aside around merge, rebase and pull, and brought back once the
|
||||
//! operation is over: staged changes as staged (git's `--autostash` brings everything
|
||||
//! back unstaged). An operation that stops on conflicts keeps them aside until it is
|
||||
//! continued, skipped, aborted or concluded by a commit.
|
||||
|
||||
use crate::Repo;
|
||||
use crate::cli::{self, run};
|
||||
use crate::write::unmerged_paths;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
|
||||
/// `.git/<MARKER>` holds the id of the stash the changes went to.
|
||||
const MARKER: &str = "openidea-saved-changes";
|
||||
|
||||
/// Stashes the local changes of tracked files, if any.
|
||||
pub(crate) fn save(r: &Repo, message: &str) -> IdeResult<()> {
|
||||
let marker = r.git_dir.join(MARKER);
|
||||
if marker.exists() {
|
||||
// Changes saved by an operation still in progress stay where they are; those of
|
||||
// one concluded outside the IDE come back first.
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
restore(r)?;
|
||||
}
|
||||
if run(r.root(), &["status", "--porcelain", "--untracked-files=no"], None)?.stdout_str().trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let before = run(r.root(), &["rev-parse", "-q", "--verify", "refs/stash"], None).map(|o| o.stdout_str()).ok();
|
||||
run(r.root(), &["stash", "push", "-q", "-m", message], None)?;
|
||||
let id = run(r.root(), &["rev-parse", "refs/stash"], None)?.stdout_str();
|
||||
if before.as_deref().map(str::trim) == Some(id.trim()) {
|
||||
return Err(IdeError::invalid("could not save the local changes"));
|
||||
}
|
||||
std::fs::write(marker, id.trim())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Brings the saved changes back once no operation is in progress. Fails when they come
|
||||
/// back with conflicts (they then also stay in the stash).
|
||||
pub(crate) fn restore(r: &Repo) -> IdeResult<()> {
|
||||
let marker = r.git_dir.join(MARKER);
|
||||
let Ok(saved) = std::fs::read_to_string(&marker) else { return Ok(()) };
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let _ = std::fs::remove_file(&marker);
|
||||
let saved = saved.trim();
|
||||
let stashes = run(r.root(), &["stash", "list", "--format=%H"], None)?.stdout_str();
|
||||
let Some(index) = stashes.lines().position(|id| id.trim() == saved) else { return Ok(()) };
|
||||
let entry = format!("stash@{{{index}}}");
|
||||
if cli::run_raw(r.root(), &["stash", "pop", "-q", "--index", &entry], None)?.code == Some(0) {
|
||||
return Ok(());
|
||||
}
|
||||
// The staged part no longer applies as it was: bring everything back unstaged.
|
||||
if unmerged_paths(r)?.is_empty() && cli::run_raw(r.root(), &["stash", "pop", "-q", &entry], None)?.code == Some(0) {
|
||||
return Ok(());
|
||||
}
|
||||
Err(IdeError::invalid("local changes were restored with conflicts; resolve them (a copy stays in the stash)"))
|
||||
}
|
||||
|
||||
/// Runs `op` with the local changes put aside, and brings them back if it finished.
|
||||
pub(crate) fn around<T>(r: &Repo, message: &str, op: impl FnOnce() -> IdeResult<T>) -> IdeResult<T> {
|
||||
save(r, message)?;
|
||||
let result = op();
|
||||
let restored = restore(r);
|
||||
let value = result?;
|
||||
restored.map(|_| value)
|
||||
}
|
||||
+33
-237
@@ -372,13 +372,11 @@ pub(crate) fn commit(
|
||||
args.extend(["-F", "-"]);
|
||||
Some(message.as_bytes())
|
||||
};
|
||||
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() {
|
||||
if options.partial.is_empty() {
|
||||
run(r.root(), &args, stdin)?;
|
||||
} else {
|
||||
commit_selected(r, &args, stdin, options, encoding)?;
|
||||
commit_partial(r, &args, stdin, &options.partial, encoding)?;
|
||||
}
|
||||
// A commit concluding a merge brings back the local changes put aside for it.
|
||||
let _ = crate::preserve::restore(r);
|
||||
let repo = r.gix()?;
|
||||
Ok(repo.head_id().map_err(gx)?.detach().to_string())
|
||||
}
|
||||
@@ -393,16 +391,15 @@ impl Drop for TempIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`):
|
||||
/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and
|
||||
/// `partial` only the chosen hunks. The working tree and the user's index are left alone
|
||||
/// except that the committed files' entries end up matching the new commit, so changes
|
||||
/// left out keep their staged state.
|
||||
fn commit_selected(
|
||||
/// Commits the index with only the chosen hunks of `partial` files, through a copy of the
|
||||
/// index (`GIT_INDEX_FILE`), as IDEA's partial commit does: the working tree and the
|
||||
/// user's index are left alone except that the partial files' entries end up matching
|
||||
/// the new commit.
|
||||
fn commit_partial(
|
||||
r: &Repo,
|
||||
commit_args: &[&str],
|
||||
stdin: Option<&[u8]>,
|
||||
options: &CommitOptions,
|
||||
partial: &[crate::PartialCommitFile],
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
|
||||
@@ -411,22 +408,8 @@ fn commit_selected(
|
||||
std::fs::copy(&index, &tmp.0)?;
|
||||
}
|
||||
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
|
||||
let rel_paths = |paths: &[ide_core::IpcPath]| -> IdeResult<Vec<String>> {
|
||||
let paths: Vec<PathBuf> = paths.iter().map(|p| p.0.clone()).collect();
|
||||
r.rel_paths(&paths)
|
||||
};
|
||||
let exclude = rel_paths(&options.exclude)?;
|
||||
if !exclude.is_empty() {
|
||||
let args: &[&str] = if r.has_head()? {
|
||||
&["--literal-pathspecs", "reset", "-q", "HEAD", "--"]
|
||||
} else {
|
||||
&["--literal-pathspecs", "rm", "--cached", "-r", "-q", "--ignore-unmatch", "--"]
|
||||
};
|
||||
cli::run_paths_env(r.root(), args, &exclude, None, &env)?;
|
||||
}
|
||||
let mut rels = rel_paths(&options.stage)?;
|
||||
cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], &rels, None, &env)?;
|
||||
for file in &options.partial {
|
||||
let mut rels = Vec::new();
|
||||
for file in partial {
|
||||
let rel = r.rel_path(&file.path.0)?;
|
||||
let head = {
|
||||
let repo = r.gix()?;
|
||||
@@ -542,16 +525,8 @@ fn switch_plan(r: &Repo, branch: &str, create: bool, diverged: Option<DivergedLo
|
||||
|
||||
/// Files git refuses to overwrite with local changes, from its error message.
|
||||
fn overwritten_files(stderr: &str) -> Option<Vec<String>> {
|
||||
listed_files(stderr, "local changes to the following files would be overwritten")
|
||||
}
|
||||
|
||||
/// Untracked files git refuses to overwrite, from its error message.
|
||||
fn overwritten_untracked(stderr: &str) -> Option<Vec<String>> {
|
||||
listed_files(stderr, "untracked working tree files would be")
|
||||
}
|
||||
|
||||
fn listed_files(stderr: &str, header: &str) -> Option<Vec<String>> {
|
||||
let mut lines = stderr.lines().skip_while(|l| !l.contains(header));
|
||||
let mut lines =
|
||||
stderr.lines().skip_while(|l| !l.contains("local changes to the following files would be overwritten"));
|
||||
lines.next()?;
|
||||
Some(lines.take_while(|l| l.starts_with('\t')).map(|l| l.trim().to_string()).collect())
|
||||
}
|
||||
@@ -572,13 +547,10 @@ pub(crate) fn checkout_with(
|
||||
let mut args = match switch_plan(r, branch, create, options.diverged)? {
|
||||
SwitchPlan::Ahead { local, remote } => return Ok(CheckoutOutcome::LocalBranchAhead { local, remote }),
|
||||
SwitchPlan::Rebase { local, remote } => {
|
||||
crate::preserve::save(r, "Local changes saved before checkout")?;
|
||||
let out = cli::run_raw(r.root(), &["rebase", &remote, &local], None)?;
|
||||
let out = cli::run_raw(r.root(), &["rebase", "--autostash", &remote, &local], None)?;
|
||||
if out.code == Some(0) {
|
||||
crate::preserve::restore(r)?;
|
||||
return Ok(CheckoutOutcome::Done);
|
||||
}
|
||||
crate::preserve::restore(r)?;
|
||||
let files = unmerged_paths(r)?;
|
||||
if files.is_empty() {
|
||||
return Err(cli::failure(&["rebase"], &out.stderr, &out.stdout, out.code));
|
||||
@@ -595,11 +567,7 @@ pub(crate) fn checkout_with(
|
||||
if out.code == Some(0) {
|
||||
return Ok(CheckoutOutcome::Done);
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&out.stderr);
|
||||
if let Some(files) = overwritten_untracked(&stderr) {
|
||||
return Ok(CheckoutOutcome::UntrackedFiles { files });
|
||||
}
|
||||
let Some(files) = overwritten_files(&stderr) else {
|
||||
let Some(files) = overwritten_files(&String::from_utf8_lossy(&out.stderr)) else {
|
||||
return Err(cli::failure(&argv, &out.stderr, &out.stdout, out.code));
|
||||
};
|
||||
if options.mode != CheckoutMode::Smart {
|
||||
@@ -615,17 +583,7 @@ pub(crate) fn checkout_with(
|
||||
run(r.root(), &["stash", "pop", "-q", "--index"], None)?;
|
||||
return Err(e);
|
||||
}
|
||||
// Staged changes come back staged; when the index can't be restored as it was (it
|
||||
// conflicts with the new branch), the changes come back unstaged.
|
||||
let pop = cli::run_raw(r.root(), &["stash", "pop", "-q", "--index"], None)?;
|
||||
if pop.code == Some(0) {
|
||||
return Ok(CheckoutOutcome::Done);
|
||||
}
|
||||
let pop = if unmerged_paths(r)?.is_empty() && stash_count(r)? > before {
|
||||
cli::run_raw(r.root(), &["stash", "pop", "-q"], None)?
|
||||
} else {
|
||||
pop
|
||||
};
|
||||
let pop = cli::run_raw(r.root(), &["stash", "pop", "-q"], None)?;
|
||||
if pop.code == Some(0) {
|
||||
return Ok(CheckoutOutcome::Done);
|
||||
}
|
||||
@@ -665,104 +623,6 @@ pub(crate) fn create_branch(r: &Repo, name: &str, start: Option<&str>, checkout:
|
||||
|
||||
/// Undo Commit: moves the current branch back to the parent of `commit` (which must be
|
||||
/// HEAD), keeping its changes staged. Returns the undone commit's message.
|
||||
/// Edit Commit Message: writes `commit` again with `message`, and its descendants on top
|
||||
/// of it with their own trees, then moves HEAD to the new tip. Trees don't change, so the
|
||||
/// index and the working tree stay as they are (local changes included). Returns HEAD
|
||||
/// before and after, for [`restore_head`].
|
||||
pub(crate) fn reword(r: &Repo, commit: &str, message: &str) -> IdeResult<(String, String)> {
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Err(IdeError::invalid(
|
||||
"cannot edit a commit message while a merge, rebase or cherry-pick is in progress",
|
||||
));
|
||||
}
|
||||
let message = message.trim();
|
||||
if message.is_empty() {
|
||||
return Err(IdeError::invalid("commit message is empty"));
|
||||
}
|
||||
let (target, head) = {
|
||||
let repo = r.gix()?;
|
||||
let head = repo.head_id().map_err(|_| IdeError::invalid("there is no commit to edit"))?.detach();
|
||||
let target = repo.find_object(resolve(&repo, commit)?).map_err(gx)?.peel_to_commit().map_err(gx)?.id;
|
||||
(target.to_string(), head.to_string())
|
||||
};
|
||||
if cli::run_raw(r.root(), &["merge-base", "--is-ancestor", &target, &head], None)?.code != Some(0) {
|
||||
return Err(IdeError::invalid("the commit is not in the current branch"));
|
||||
}
|
||||
// target..HEAD must be a straight line on top of the target.
|
||||
let range = format!("{target}..{head}");
|
||||
let listed = run(r.root(), &["rev-list", "--reverse", "--topo-order", "--parents", &range], None)?.stdout_str();
|
||||
let mut chain = vec![target.clone()];
|
||||
for line in listed.lines() {
|
||||
let ids: Vec<&str> = line.split_whitespace().collect();
|
||||
if ids.len() != 2 || ids[1] != chain.last().unwrap() {
|
||||
return Err(IdeError::invalid("the commits after this one are not a linear history"));
|
||||
}
|
||||
chain.push(ids[0].to_string());
|
||||
}
|
||||
let mut parent: Option<String> = None;
|
||||
for (i, id) in chain.iter().enumerate() {
|
||||
let raw = run(r.root(), &["cat-file", "commit", id], None)?.stdout;
|
||||
let text = String::from_utf8_lossy(&raw);
|
||||
let (headers, body) = text.split_once("\n\n").unwrap_or((&text, ""));
|
||||
let mut tree = "";
|
||||
let mut parents = Vec::new();
|
||||
let mut author = "";
|
||||
for line in headers.lines() {
|
||||
if let Some(v) = line.strip_prefix("tree ") {
|
||||
tree = v;
|
||||
} else if let Some(v) = line.strip_prefix("parent ") {
|
||||
parents.push(v.to_string());
|
||||
} else if let Some(v) = line.strip_prefix("author ") {
|
||||
author = v;
|
||||
}
|
||||
}
|
||||
if parents.len() > 1 {
|
||||
return Err(IdeError::invalid("merge commits cannot be reworded"));
|
||||
}
|
||||
if let Some(p) = &parent {
|
||||
parents = vec![p.clone()];
|
||||
}
|
||||
let (name, rest) = author.split_once(" <").unwrap_or((author, ""));
|
||||
let (email, date) = rest.split_once("> ").unwrap_or((rest, ""));
|
||||
let env = [
|
||||
("GIT_AUTHOR_NAME", std::ffi::OsStr::new(name)),
|
||||
("GIT_AUTHOR_EMAIL", std::ffi::OsStr::new(email)),
|
||||
("GIT_AUTHOR_DATE", std::ffi::OsStr::new(date)),
|
||||
];
|
||||
let mut args = vec!["commit-tree", tree];
|
||||
for p in &parents {
|
||||
args.extend(["-p", p.as_str()]);
|
||||
}
|
||||
let new_message = if i == 0 { format!("{message}\n") } else { body.to_string() };
|
||||
let created = cli::run_env(r.root(), &args, Some(new_message.as_bytes()), &env)?.stdout_str();
|
||||
parent = Some(created.trim().to_string());
|
||||
}
|
||||
let new_head = parent.expect("the chain holds the target");
|
||||
let reason = format!("reword: {}", message.lines().next().unwrap_or_default());
|
||||
run(r.root(), &["update-ref", "-m", &reason, "HEAD", &new_head, &head], None)?;
|
||||
Ok((head, new_head))
|
||||
}
|
||||
|
||||
/// Undo of a history rewrite: moves HEAD from `after` back to `before`, keeping local
|
||||
/// changes (`git reset --keep`). Refused when HEAD has moved since.
|
||||
pub(crate) fn restore_head(r: &Repo, before: &str, after: &str) -> IdeResult<()> {
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Err(IdeError::invalid("cannot undo while a merge, rebase or cherry-pick is in progress"));
|
||||
}
|
||||
let head = run(r.root(), &["rev-parse", "--verify", "HEAD"], None)?.stdout_str();
|
||||
if head.trim() != after {
|
||||
return Err(IdeError::invalid("the branch has changed since; undo is no longer possible"));
|
||||
}
|
||||
let before = check_rev(before)?;
|
||||
let tree = |rev: &str| run(r.root(), &["rev-parse", &format!("{rev}^{{tree}}")], None).map(|o| o.stdout_str());
|
||||
if tree(before)? == tree(after)? {
|
||||
run(r.root(), &["update-ref", "-m", "undo", "HEAD", before, after], None)?;
|
||||
} else {
|
||||
run(r.root(), &["reset", "-q", "--keep", before, "--"], None)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn undo_commit(r: &Repo, commit: &str) -> IdeResult<String> {
|
||||
if r.branch_info()?.operation.is_some() {
|
||||
return Err(IdeError::invalid("cannot undo a commit while a merge, rebase or cherry-pick is in progress"));
|
||||
@@ -851,17 +711,11 @@ fn check_rev(rev: &str) -> IdeResult<&str> {
|
||||
|
||||
/// Local changes are stashed and restored around the merge (IDEA's "save local changes").
|
||||
pub(crate) fn merge(r: &Repo, branch: &str) -> IdeResult<()> {
|
||||
let branch = check_rev(branch)?;
|
||||
crate::preserve::around(r, "Local changes saved before merge", || {
|
||||
run(r.root(), &["merge", "--no-edit", branch], None).map(|_| ())
|
||||
})
|
||||
run(r.root(), &["merge", "--no-edit", "--autostash", check_rev(branch)?], None).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn rebase(r: &Repo, onto: &str) -> IdeResult<()> {
|
||||
let onto = check_rev(onto)?;
|
||||
crate::preserve::around(r, "Local changes saved before rebase", || {
|
||||
run(r.root(), &["rebase", onto], None).map(|_| ())
|
||||
})
|
||||
run(r.root(), &["rebase", "--autostash", check_rev(onto)?], None).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn stash_push(r: &Repo, message: Option<&str>, include_untracked: bool, keep_index: bool) -> IdeResult<()> {
|
||||
@@ -927,16 +781,20 @@ pub(crate) fn delete_remote_branch(
|
||||
|
||||
pub(crate) fn push(
|
||||
r: &Repo,
|
||||
o: crate::PushOptions,
|
||||
force_with_lease: bool,
|
||||
set_upstream: bool,
|
||||
tags: Option<crate::PushTags>,
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let mut args: Vec<String> = vec!["push".into(), "--progress".into()];
|
||||
args.extend(force_args(o.force, o.force_with_lease, o.no_verify));
|
||||
if o.tags == Some(crate::PushTags::CurrentBranch) {
|
||||
if force_with_lease {
|
||||
args.push("--force-with-lease".into());
|
||||
}
|
||||
if tags == Some(crate::PushTags::CurrentBranch) {
|
||||
args.push("--follow-tags".into());
|
||||
}
|
||||
if o.set_upstream {
|
||||
if set_upstream {
|
||||
let repo = r.gix()?;
|
||||
let head =
|
||||
repo.head_name().map_err(gx)?.ok_or_else(|| IdeError::invalid("cannot set upstream: HEAD is detached"))?;
|
||||
@@ -959,49 +817,14 @@ pub(crate) fn push(
|
||||
}
|
||||
let argv: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
cli::run_streaming(r.root(), &argv, cancel, on_output)?;
|
||||
if o.tags == Some(crate::PushTags::All) {
|
||||
if tags == Some(crate::PushTags::All) {
|
||||
// `git push --tags` alone pushes only tags, so it runs after the branch push.
|
||||
let remote = push_remote(r)?;
|
||||
let mut args = vec!["push", "--progress"];
|
||||
if o.no_verify {
|
||||
args.push("--no-verify");
|
||||
}
|
||||
args.extend([remote.as_str(), "--tags"]);
|
||||
cli::run_streaming(r.root(), &args, cancel, on_output)?;
|
||||
cli::run_streaming(r.root(), &["push", "--progress", &remote, "--tags"], cancel, on_output)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `--force` / `--force-with-lease` and `--no-verify` of a push.
|
||||
fn force_args(force: bool, force_with_lease: bool, no_verify: bool) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if force {
|
||||
args.push("--force".into());
|
||||
} else if force_with_lease {
|
||||
args.push("--force-with-lease".into());
|
||||
}
|
||||
if no_verify {
|
||||
args.push("--no-verify".into());
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
pub(crate) fn has_hook(r: &Repo, name: &str) -> IdeResult<bool> {
|
||||
if name.is_empty() || name.contains(['/', '\\']) {
|
||||
return Err(IdeError::invalid(format!("invalid hook name '{name}'")));
|
||||
}
|
||||
let path = run(r.root(), &["rev-parse", "--git-path", &format!("hooks/{name}")], None)?.stdout_str();
|
||||
let path = r.root().join(path.trim());
|
||||
let Ok(meta) = std::fs::metadata(&path) else { return Ok(false) };
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
Ok(meta.is_file() && meta.permissions().mode() & 0o111 != 0)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
Ok(meta.is_file())
|
||||
}
|
||||
|
||||
/// Remote and branch name a local branch pulls from (`branch.<b>.remote` / `.merge`).
|
||||
fn branch_upstream(r: &Repo, branch: &str) -> Option<(String, String)> {
|
||||
let get = |key: &str| {
|
||||
@@ -1032,18 +855,10 @@ pub(crate) fn push_branch(
|
||||
cancel: &CancellationToken,
|
||||
on_output: &dyn Fn(String),
|
||||
) -> IdeResult<()> {
|
||||
let PushBranchOptions {
|
||||
revision,
|
||||
target,
|
||||
remote: remote_override,
|
||||
force_with_lease,
|
||||
force,
|
||||
set_upstream,
|
||||
no_verify,
|
||||
} = opts;
|
||||
let PushBranchOptions { revision, target, remote: remote_override, force_with_lease } = opts;
|
||||
check_local_branch(r, branch)?;
|
||||
let remote_override = remote_override.map(str::trim).filter(|s| !s.is_empty());
|
||||
let (remote, mut name, untracked) = match (remote_override, branch_upstream(r, branch)) {
|
||||
let (remote, mut name, set_upstream) = match (remote_override, branch_upstream(r, branch)) {
|
||||
// An explicit remote keeps the upstream's branch name but pushes elsewhere.
|
||||
(Some(remote), Some((_, name))) => (check_rev(remote)?.to_string(), name, false),
|
||||
(Some(remote), None) => (check_rev(remote)?.to_string(), branch.to_string(), revision.is_none()),
|
||||
@@ -1064,8 +879,10 @@ pub(crate) fn push_branch(
|
||||
None => format!("refs/heads/{branch}"),
|
||||
};
|
||||
let mut args: Vec<String> = vec!["push".into(), "--progress".into()];
|
||||
args.extend(force_args(force, force_with_lease, no_verify));
|
||||
if untracked || (set_upstream && revision.is_none()) {
|
||||
if force_with_lease {
|
||||
args.push("--force-with-lease".into());
|
||||
}
|
||||
if set_upstream {
|
||||
args.push("--set-upstream".into());
|
||||
}
|
||||
args.extend([remote, format!("{source}:refs/heads/{target}")]);
|
||||
@@ -1087,27 +904,6 @@ pub(crate) fn update_branch(
|
||||
cli::run_streaming(r.root(), &["fetch", "--progress", &remote, &refspec], cancel, on_output)
|
||||
}
|
||||
|
||||
/// Update Project › Reset to the Remote Branch: fetches the current branch's tracked
|
||||
/// branch and moves the branch to it, dropping local commits. Local changes are put
|
||||
/// aside and brought back (an error with the conflicts left in place when they clash).
|
||||
pub(crate) fn reset_to_upstream(r: &Repo, cancel: &CancellationToken, on_output: &dyn Fn(String)) -> IdeResult<()> {
|
||||
let head = run(r.root(), &["symbolic-ref", "-q", "--short", "HEAD"], None)
|
||||
.map_err(|_| IdeError::invalid("cannot reset: HEAD is detached"))?
|
||||
.stdout_str();
|
||||
let head = head.trim();
|
||||
let no_tracked =
|
||||
|| IdeError::invalid(format!("no tracked branch configured for branch {head} or the branch doesn't exist"));
|
||||
let (remote, name) = branch_upstream(r, head).ok_or_else(no_tracked)?;
|
||||
cli::run_streaming(r.root(), &["fetch", "--progress", &remote], cancel, on_output)?;
|
||||
let tracked = format!("refs/remotes/{remote}/{name}");
|
||||
if run(r.root(), &["rev-parse", "-q", "--verify", &format!("{tracked}^{{commit}}")], None).is_err() {
|
||||
return Err(no_tracked());
|
||||
}
|
||||
crate::preserve::around(r, "Local changes saved before reset", || {
|
||||
run(r.root(), &["reset", "-q", "--keep", &tracked], None).map(|_| ())
|
||||
})
|
||||
}
|
||||
|
||||
/// The remote the current branch pushes to (`origin`, or the only remote, by default).
|
||||
/// The remote and ref HEAD's branch tracks when the ref has another name: a plain
|
||||
/// `git push` refuses that under the default `push.default=simple`.
|
||||
|
||||
@@ -306,17 +306,6 @@ fn interactive_rebase_reorder_squash_fixup_reword_drop() {
|
||||
assert_eq!(fx.git(&["show", "--format=", "--name-only", "HEAD"]), "f1.txt\nf2.txt\nf3.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_reword_keeps_lines_starting_with_a_hash() {
|
||||
let (fx, ids) = linear();
|
||||
let (cancel, out) = quiet();
|
||||
let message = "Subject\n\n#body starting with a hash";
|
||||
fx.repo()
|
||||
.interactive_rebase(Some(&ids[2]), &[entry(&ids[3], RebaseAction::Reword, Some(message))], &cancel, &out)
|
||||
.unwrap();
|
||||
assert_eq!(fx.git(&["log", "-1", "--format=%B"]), message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_squash_without_message_combines_messages() {
|
||||
let (fx, ids) = linear();
|
||||
@@ -418,89 +407,6 @@ fn interactive_rebase_conflict_stops_then_continues_with_reword() {
|
||||
assert_eq!(fx.subjects(), vec!["one", "two first", "base"]);
|
||||
}
|
||||
|
||||
/// `main` and `feature` both change `c.txt`: rebasing feature onto main stops on it.
|
||||
fn rebase_stopped_on_a_conflict() -> (Fixture, Repo) {
|
||||
let fx = Fixture::new();
|
||||
fx.write("c.txt", "base\n");
|
||||
fx.write("other.txt", "other\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["switch", "-q", "-c", "feature"]);
|
||||
fx.write("c.txt", "feature\n");
|
||||
fx.commit_all("feature change");
|
||||
fx.git(&["switch", "-q", "main"]);
|
||||
fx.write("c.txt", "main\n");
|
||||
fx.commit_all("main change");
|
||||
fx.git(&["switch", "-q", "feature"]);
|
||||
let repo = fx.repo();
|
||||
assert!(repo.rebase("main").is_err());
|
||||
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase"));
|
||||
(fx, repo)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_of_a_resolved_file_go_with_it_when_the_rebase_continues() {
|
||||
let (fx, repo) = rebase_stopped_on_a_conflict();
|
||||
fx.write("c.txt", "resolved\n");
|
||||
repo.stage(&[fx.path("c.txt")]).unwrap();
|
||||
fx.write("c.txt", "resolved\nmore\n");
|
||||
let (cancel, out) = quiet();
|
||||
repo.continue_operation(&cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.git(&["show", "HEAD:c.txt"]), "resolved\nmore");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstaged_changes_of_other_files_stop_the_rebase_until_staged() {
|
||||
let (fx, repo) = rebase_stopped_on_a_conflict();
|
||||
fx.write("c.txt", "resolved\n");
|
||||
repo.stage(&[fx.path("c.txt")]).unwrap();
|
||||
fx.write("other.txt", "changed while stopped\n");
|
||||
let (cancel, out) = quiet();
|
||||
let err = repo.continue_operation(&cancel, &out).unwrap_err();
|
||||
assert_eq!(err.message, UNSTAGED_CHANGES_PREVENT_REBASE);
|
||||
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase"));
|
||||
repo.stage_and_continue(&cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.git(&["show", "HEAD:other.txt"]), "changed while stopped");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stage_and_retry_after_editing_a_commit() {
|
||||
let (fx, ids) = linear();
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
let plan = [entry(&ids[2], RebaseAction::Edit, None), entry(&ids[3], RebaseAction::Pick, None)];
|
||||
repo.interactive_rebase(Some(&ids[1]), &plan, &cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase"));
|
||||
fx.write("f1.txt", "edited\n");
|
||||
std::fs::remove_file(fx.path("f2.txt")).unwrap();
|
||||
assert!(repo.continue_operation(&cancel, &out).is_err());
|
||||
repo.stage_and_continue(&cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.git(&["status", "--porcelain"]), "");
|
||||
assert!(!fx.exists("f2.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebase_and_merge_onto_a_branch_named_like_a_tag_use_the_branch() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["tag", "main"]);
|
||||
fx.write("m.txt", "m\n");
|
||||
let tip = fx.commit_all("main moved on");
|
||||
fx.git(&["switch", "-q", "-c", "feature", "HEAD~1"]);
|
||||
fx.write("f.txt", "f\n");
|
||||
fx.commit_all("feature");
|
||||
let repo = fx.repo();
|
||||
repo.rebase("refs/heads/main").unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), tip);
|
||||
fx.git(&["reset", "-q", "--hard", "HEAD~1"]);
|
||||
fx.git(&["switch", "-q", "-c", "other", "refs/heads/main~1"]);
|
||||
repo.merge("refs/heads/main").unwrap();
|
||||
assert_eq!(fx.head(), tip, "fast-forwarded to the branch, not the tag");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_edit_stops_for_amend() {
|
||||
let (fx, ids) = linear();
|
||||
@@ -618,157 +524,6 @@ fn update_merge_with_local_changes_to_the_same_file() {
|
||||
assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_rebase_keeps_a_file_added_then_deleted_added_to_the_index() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
fx.push_from_other(&bare, "remote.txt", "r\n");
|
||||
fx.write("local.txt", "l\n");
|
||||
fx.commit_all("local");
|
||||
fx.write("ad.txt", "x\n");
|
||||
fx.git(&["add", "ad.txt"]);
|
||||
std::fs::remove_file(fx.path("ad.txt")).unwrap();
|
||||
let (cancel, out) = quiet();
|
||||
fx.repo().pull(true, &cancel, &out).unwrap();
|
||||
assert!(fx.exists("remote.txt"));
|
||||
assert!(fx.git(&["status", "--porcelain", "--", "ad.txt"]).starts_with('A'), "still added");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_the_remote_branch_drops_local_commits_and_keeps_local_changes() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
for i in 0..3 {
|
||||
fx.push_from_other(&bare, "bro.txt", &format!("bro {i}\n"));
|
||||
}
|
||||
fx.write("local1.txt", "1\n");
|
||||
fx.commit_all("local commit");
|
||||
fx.write("untracked.txt", "u\n");
|
||||
fx.write("a.txt", "edited\n");
|
||||
fx.git(&["add", "a.txt"]);
|
||||
let (cancel, out) = quiet();
|
||||
fx.repo().reset_to_upstream(&cancel, &out).unwrap();
|
||||
assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"]));
|
||||
assert!(!fx.exists("local1.txt"), "files of local commits are gone");
|
||||
assert!(fx.exists("untracked.txt"));
|
||||
assert_eq!(fx.git(&["status", "--porcelain", "--", "a.txt"]), "M a.txt", "staged changes stay staged");
|
||||
assert_eq!(fx.git(&["stash", "list"]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_the_remote_branch_without_new_remote_commits_drops_local_ones() {
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
let before = fx.head();
|
||||
fx.write("local1.txt", "1\n");
|
||||
fx.commit_all("local commit");
|
||||
let (cancel, out) = quiet();
|
||||
fx.repo().reset_to_upstream(&cancel, &out).unwrap();
|
||||
assert_eq!(fx.head(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_the_remote_branch_leaves_conflicting_local_changes_to_resolve() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
fx.push_from_other(&bare, "bro.txt", "bro\n");
|
||||
fx.write("bro.txt", "local content\n");
|
||||
fx.git(&["add", "bro.txt"]);
|
||||
let (cancel, out) = quiet();
|
||||
assert!(fx.repo().reset_to_upstream(&cancel, &out).is_err());
|
||||
assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"]));
|
||||
assert!(fx.read("bro.txt").contains("<<<<<<<"), "the clash is left to resolve");
|
||||
assert_eq!(fx.git(&["stash", "list"]).lines().count(), 1, "a copy stays in the stash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_to_the_remote_branch_needs_a_tracked_branch_that_exists() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
let (cancel, out) = quiet();
|
||||
fx.git(&["branch", "--unset-upstream"]);
|
||||
let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err();
|
||||
assert!(err.message.contains("no tracked branch"), "{}", err.message);
|
||||
|
||||
fx.git(&["push", "-q", "-u", "origin", "main:feature"]);
|
||||
fx.git(&["switch", "-q", "-c", "feature", "--track", "origin/feature"]);
|
||||
git_in(&bare, &["branch", "-D", "feature"]);
|
||||
fx.git(&["fetch", "-q", "--prune"]);
|
||||
let err = fx.repo().reset_to_upstream(&cancel, &out).unwrap_err();
|
||||
assert!(err.message.contains("no tracked branch"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn push_can_skip_a_failing_pre_push_hook() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
let repo = fx.repo();
|
||||
assert!(!repo.has_hook("pre-push").unwrap());
|
||||
let hook = fx.path(".git/hooks/pre-push");
|
||||
std::fs::create_dir_all(hook.parent().unwrap()).unwrap();
|
||||
std::fs::write(&hook, "#!/bin/sh\necho refused >&2\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(repo.has_hook("pre-push").unwrap());
|
||||
|
||||
fx.write("local.txt", "l\n");
|
||||
fx.commit_all("local");
|
||||
let (cancel, out) = quiet();
|
||||
assert!(repo.push(false, false, &cancel, &out).is_err(), "the hook refuses the push");
|
||||
let options = PushOptions { no_verify: true, ..Default::default() };
|
||||
repo.push_with(options, &cancel, &out).unwrap();
|
||||
assert_eq!(fx.head(), fx.git(&["rev-parse", "origin/main"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_push_without_lease_overwrites_a_remote_that_moved() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
fx.write("local.txt", "l\n");
|
||||
fx.commit_all("local");
|
||||
fx.push_from_other(&bare, "remote.txt", "r\n");
|
||||
fx.git(&["fetch", "-q"]);
|
||||
fx.push_from_other(&bare, "remote2.txt", "r\n");
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
let lease = PushOptions { force_with_lease: true, ..Default::default() };
|
||||
let err = repo.push_with(lease, &cancel, &out).unwrap_err();
|
||||
assert!(err.message.contains("stale info"), "{}", err.message);
|
||||
repo.push_with(PushOptions { force: true, ..Default::default() }, &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.head());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_to_another_branch_can_make_it_the_upstream() {
|
||||
let (fx, _bare) = Fixture::with_origin();
|
||||
fx.write("local.txt", "l\n");
|
||||
fx.commit_all("local");
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
let to = |target, set_upstream| PushBranchOptions { target: Some(target), set_upstream, ..Default::default() };
|
||||
repo.push_branch_to("main", to("feature", false), &cancel, &out).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/main", "upstream kept");
|
||||
repo.push_branch_to("main", to("feature-1", true), &cancel, &out).unwrap();
|
||||
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{u}"]), "origin/feature-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_are_pushed_one_at_a_time_deleted_restored_and_deleted_on_the_remote() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
fx.git(&["tag", "-a", "v1", "-m", "release one"]);
|
||||
fx.git(&["tag", "v2"]);
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
repo.push_tag("v1", "origin", &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare, &["tag"]), "v1", "only the chosen tag goes");
|
||||
repo.push_tag("v1", "origin", &cancel, &out).unwrap();
|
||||
|
||||
let deleted = repo.delete_tag("v1").unwrap();
|
||||
assert_eq!(fx.git(&["tag"]), "v2");
|
||||
repo.restore_tag(&deleted).unwrap();
|
||||
assert_eq!(fx.git(&["cat-file", "-t", "v1"]), "tag", "annotated, as it was");
|
||||
assert!(fx.git(&["tag", "-n1", "v1"]).contains("release one"));
|
||||
|
||||
repo.delete_tag("v1").unwrap();
|
||||
repo.delete_remote_tag("v1", "origin", &cancel, &out).unwrap();
|
||||
assert_eq!(git_in(&bare, &["tag"]), "");
|
||||
assert!(repo.delete_tag("missing").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_prunes_deleted_remote_branches() {
|
||||
let (fx, bare) = Fixture::with_origin();
|
||||
@@ -1108,100 +863,3 @@ fn remote_progress_is_streamed() {
|
||||
fx.repo().push(false, false, &CancellationToken::new(), &move |l| sink.lock().unwrap().push(l)).unwrap();
|
||||
assert!(!lines.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_rebase_restores_local_changes_staged_as_they_were() {
|
||||
let (fx, ids) = linear();
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
fx.write("f1.txt", "staged\n");
|
||||
fx.git(&["add", "f1.txt"]);
|
||||
fx.write("f2.txt", "unstaged\n");
|
||||
repo.interactive_rebase(
|
||||
Some(&ids[1]),
|
||||
&[entry(&ids[2], RebaseAction::Drop, None), entry(&ids[3], RebaseAction::Pick, None)],
|
||||
&cancel,
|
||||
&out,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(fx.subjects(), vec!["c4", "c2", "c1"]);
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt");
|
||||
assert!(fx.git(&["stash", "list"]).is_empty());
|
||||
assert!(!fx.path(".git/openidea-rebase").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_changes_come_back_once_a_stopped_rebase_is_continued() {
|
||||
let (fx, ids) = linear();
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
// c4 rewrites f3.txt so that dropping c3 conflicts.
|
||||
fx.write("f3.txt", "3\nmore\n");
|
||||
fx.commit_all("c5");
|
||||
let c5 = fx.git(&["rev-parse", "HEAD"]);
|
||||
fx.write("f1.txt", "staged\n");
|
||||
fx.git(&["add", "f1.txt"]);
|
||||
let result = repo.interactive_rebase(
|
||||
Some(&ids[1]),
|
||||
&[
|
||||
entry(&ids[2], RebaseAction::Drop, None),
|
||||
entry(&ids[3], RebaseAction::Pick, None),
|
||||
entry(&c5, RebaseAction::Pick, None),
|
||||
],
|
||||
&cancel,
|
||||
&out,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase"));
|
||||
assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the rebase is stopped");
|
||||
fx.write("f3.txt", "3\nmore\n");
|
||||
repo.stage(&[fx.path("f3.txt")]).unwrap();
|
||||
repo.continue_operation(&cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.read("f1.txt"), "staged\n");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt");
|
||||
assert!(fx.git(&["stash", "list"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_brings_staged_changes_back_staged() {
|
||||
let (fx, _ids) = linear();
|
||||
let repo = fx.repo();
|
||||
fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]);
|
||||
fx.write("side.txt", "side\n");
|
||||
fx.git(&["add", "side.txt"]);
|
||||
fx.git(&["commit", "-q", "-m", "side"]);
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
fx.write("f1.txt", "staged\n");
|
||||
fx.git(&["add", "f1.txt"]);
|
||||
fx.write("f2.txt", "unstaged\n");
|
||||
repo.merge_with(&["side".to_string()], &MergeOptions::default()).unwrap();
|
||||
assert!(fx.exists("side.txt"));
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "f2.txt");
|
||||
assert!(fx.git(&["stash", "list"]).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_changes_come_back_after_a_conflicting_merge_is_committed() {
|
||||
let (fx, _ids) = linear();
|
||||
let repo = fx.repo();
|
||||
fx.git(&["checkout", "-q", "-b", "side", "HEAD~1"]);
|
||||
fx.write("f4.txt", "side\n");
|
||||
fx.git(&["add", "f4.txt"]);
|
||||
fx.git(&["commit", "-q", "-m", "side f4"]);
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
fx.write("f1.txt", "staged\n");
|
||||
fx.git(&["add", "f1.txt"]);
|
||||
assert!(repo.merge_with(&["side".to_string()], &MergeOptions::default()).is_err());
|
||||
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge"));
|
||||
assert_eq!(fx.read("f1.txt"), "1\n", "put aside while the merge is in progress");
|
||||
fx.write("f4.txt", "both\n");
|
||||
repo.stage(&[fx.path("f4.txt")]).unwrap();
|
||||
repo.commit("Merge side", false, false).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.read("f1.txt"), "staged\n");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "f1.txt");
|
||||
assert!(fx.git(&["stash", "list"]).is_empty());
|
||||
}
|
||||
@@ -662,45 +662,3 @@ fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() {
|
||||
shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap();
|
||||
assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cherry_pick_skips_commits_already_applied() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "1\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["checkout", "-q", "-b", "side"]);
|
||||
fx.write("a.txt", "2\n");
|
||||
let same = fx.commit_all("same change");
|
||||
fx.write("b.txt", "b\n");
|
||||
let other = fx.commit_all("other change");
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
// main already has the first change.
|
||||
fx.write("a.txt", "2\n");
|
||||
fx.commit_all("same change, made on main");
|
||||
let repo = fx.repo();
|
||||
let cancel = CancellationToken::new();
|
||||
let lines = std::sync::Mutex::new(Vec::new());
|
||||
let out = |l: String| lines.lock().unwrap().push(l);
|
||||
|
||||
repo.cherry_pick_with(&[same.clone(), other], true, &cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None, "nothing left in progress");
|
||||
assert_eq!(fx.git(&["log", "--format=%s", "-2"]), "other change\nsame change, made on main");
|
||||
assert!(fx.git(&["log", "-1", "--format=%B"]).contains("(cherry picked from commit"));
|
||||
assert!(lines.lock().unwrap().iter().any(|l| l.starts_with(&same[..8]) && l.contains("skipped")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revert_of_a_commit_already_reverted_is_skipped() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "1\n");
|
||||
fx.commit_all("one");
|
||||
fx.write("a.txt", "2\n");
|
||||
let two = fx.commit_all("two");
|
||||
fx.write("a.txt", "1\n");
|
||||
fx.commit_all("undo two by hand");
|
||||
let repo = fx.repo();
|
||||
let (cancel, out) = quiet();
|
||||
repo.revert(&[two], &cancel, &out).unwrap();
|
||||
assert_eq!(repo.branch_info().unwrap().operation, None);
|
||||
assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "3", "no empty revert commit");
|
||||
}
|
||||
@@ -462,153 +462,3 @@ fn case_insensitive_text_folds_non_ascii() {
|
||||
assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]);
|
||||
assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty());
|
||||
}
|
||||
|
||||
/// File history (Show History of a file) as (subject, path at that revision), newest first.
|
||||
fn file_history(dir: &Path, path: &str) -> Vec<(String, String)> {
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { path: Some(path.into()), follow: true, ..Default::default() }).unwrap();
|
||||
log.into_iter().map(|c| (c.summary, c.history_path.unwrap_or_default())).collect()
|
||||
}
|
||||
|
||||
fn history_repo() -> (tempfile::TempDir, std::path::PathBuf) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().to_path_buf();
|
||||
git(&dir, &["init", "-q"]);
|
||||
(tmp, dir)
|
||||
}
|
||||
|
||||
fn pairs(list: &[(&str, &str)]) -> Vec<(String, String)> {
|
||||
list.iter().map(|(a, b)| (a.to_string(), b.to_string())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_goes_through_a_rename_on_a_merged_branch() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
commit(&dir, "file.txt", "Created");
|
||||
git(&dir, &["switch", "-qc", "feature"]);
|
||||
git(&dir, &["mv", "file.txt", "renamed.txt"]);
|
||||
git(&dir, &["commit", "-qm", "Renamed"]);
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
commit(&dir, "other.txt", "Unrelated");
|
||||
git(&dir, &["merge", "-q", "--no-ff", "-m", "Merged", "feature"]);
|
||||
commit(&dir, "renamed.txt", "Modified");
|
||||
let history = file_history(&dir, "renamed.txt");
|
||||
assert_eq!(history, pairs(&[("Modified", "renamed.txt"), ("Renamed", "renamed.txt"), ("Created", "file.txt")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_goes_through_a_rename_done_in_a_merge_commit() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
commit(&dir, "file.txt", "Created");
|
||||
git(&dir, &["switch", "-qc", "feature"]);
|
||||
commit(&dir, "side.txt", "Side");
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "feature"]);
|
||||
git(&dir, &["mv", "file.txt", "renamed.txt"]);
|
||||
git(&dir, &["commit", "-qm", "Merge with rename"]);
|
||||
let history = file_history(&dir, "renamed.txt");
|
||||
assert_eq!(history, pairs(&[("Merge with rename", "renamed.txt"), ("Created", "file.txt")]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_shows_merges_that_change_the_file_but_not_trivial_ones() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
git(&dir, &["add", "-A"]);
|
||||
git(&dir, &["commit", "-qm", "Created"]);
|
||||
git(&dir, &["switch", "-qc", "feature"]);
|
||||
std::fs::write(dir.join("file.txt"), "one\ntwo\nthree\nfeature\n").unwrap();
|
||||
git(&dir, &["commit", "-qam", "Feature"]);
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
commit(&dir, "other.txt", "Other");
|
||||
git(&dir, &["merge", "-q", "--no-ff", "-m", "Trivial merge", "feature"]);
|
||||
assert_eq!(
|
||||
file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect::<Vec<_>>(),
|
||||
vec!["Feature", "Created"]
|
||||
);
|
||||
|
||||
git(&dir, &["switch", "-qc", "second", "HEAD~1"]);
|
||||
std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\n").unwrap();
|
||||
git(&dir, &["commit", "-qam", "Second"]);
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
git(&dir, &["merge", "-q", "--no-ff", "--no-commit", "second"]);
|
||||
std::fs::write(dir.join("file.txt"), "zero\none\ntwo\nthree\nfeature\nresolved\n").unwrap();
|
||||
git(&dir, &["commit", "-qam", "Non-trivial merge"]);
|
||||
let subjects: Vec<String> = file_history(&dir, "file.txt").into_iter().map(|(s, _)| s).collect();
|
||||
assert_eq!(subjects[0], "Non-trivial merge");
|
||||
assert!(subjects.contains(&"Second".to_string()) && subjects.contains(&"Feature".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_through_a_monorepo_merge_keeps_each_side() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
commit(&dir, "file1.txt", "Created file1");
|
||||
git(&dir, &["switch", "-q", "--orphan", "repo2"]);
|
||||
commit(&dir, "file2.txt", "Created file2");
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
git(&dir, &["merge", "-q", "--no-commit", "--allow-unrelated-histories", "repo2"]);
|
||||
std::fs::create_dir_all(dir.join("repo1")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("repo2")).unwrap();
|
||||
git(&dir, &["mv", "file1.txt", "repo1/file1.txt"]);
|
||||
git(&dir, &["mv", "file2.txt", "repo2/file2.txt"]);
|
||||
git(&dir, &["commit", "-qm", "monorepo merge"]);
|
||||
assert_eq!(
|
||||
file_history(&dir, "repo1/file1.txt"),
|
||||
pairs(&[("monorepo merge", "repo1/file1.txt"), ("Created file1", "file1.txt")])
|
||||
);
|
||||
assert_eq!(
|
||||
file_history(&dir, "repo2/file2.txt"),
|
||||
pairs(&[("monorepo merge", "repo2/file2.txt"), ("Created file2", "file2.txt")])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_through_parallel_renames_names_the_path_of_each_revision() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
std::fs::write(dir.join("file.txt"), "base\n").unwrap();
|
||||
git(&dir, &["add", "-A"]);
|
||||
git(&dir, &["commit", "-qm", "Created"]);
|
||||
git(&dir, &["switch", "-qc", "other"]);
|
||||
std::fs::write(dir.join("file.txt"), "base\nother\n").unwrap();
|
||||
git(&dir, &["commit", "-qam", "Modified on other"]);
|
||||
git(&dir, &["mv", "file.txt", "renamed.txt"]);
|
||||
git(&dir, &["commit", "-qm", "Renamed on other"]);
|
||||
git(&dir, &["switch", "-q", "main"]);
|
||||
std::fs::write(dir.join("file.txt"), "main\nbase\n").unwrap();
|
||||
git(&dir, &["commit", "-qam", "Modified on main"]);
|
||||
git(&dir, &["mv", "file.txt", "renamed.txt"]);
|
||||
git(&dir, &["commit", "-qm", "Renamed on main"]);
|
||||
// Both sides changed the file: the merge stops for the resolution.
|
||||
let _ = Command::new("git").current_dir(&dir).args(["merge", "-q", "--no-ff", "--no-commit", "other"]).output();
|
||||
std::fs::write(dir.join("renamed.txt"), "main\nbase\nother\nmerged\n").unwrap();
|
||||
git(&dir, &["add", "-A"]);
|
||||
git(&dir, &["commit", "-qm", "Merged other"]);
|
||||
let mut history = file_history(&dir, "renamed.txt");
|
||||
history.sort();
|
||||
assert_eq!(
|
||||
history,
|
||||
pairs(&[
|
||||
("Created", "file.txt"),
|
||||
("Merged other", "renamed.txt"),
|
||||
("Modified on main", "file.txt"),
|
||||
("Modified on other", "file.txt"),
|
||||
("Renamed on main", "renamed.txt"),
|
||||
("Renamed on other", "renamed.txt"),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_history_through_a_cyclic_rename() {
|
||||
let (_tmp, dir) = history_repo();
|
||||
commit(&dir, "a.txt", "Created a");
|
||||
git(&dir, &["mv", "a.txt", "b.txt"]);
|
||||
git(&dir, &["commit", "-qm", "a to b"]);
|
||||
commit(&dir, "b.txt", "Modified b");
|
||||
git(&dir, &["mv", "b.txt", "a.txt"]);
|
||||
git(&dir, &["commit", "-qm", "b to a"]);
|
||||
assert_eq!(
|
||||
file_history(&dir, "a.txt"),
|
||||
pairs(&[("b to a", "a.txt"), ("Modified b", "b.txt"), ("a to b", "b.txt"), ("Created a", "a.txt")])
|
||||
);
|
||||
}
|
||||
@@ -1687,173 +1687,3 @@ fn restricted_conflict_preview_uses_host_owned_temporary_files() {
|
||||
assert!(versions.merged.unwrap().contains("<<<<<<<"));
|
||||
assert_eq!(std::fs::read_to_string(marker).unwrap(), "untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_leaves_excluded_staged_changes_staged() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.write("b.txt", "b\n");
|
||||
fx.write("old.txt", "renamed\n");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
|
||||
// a.txt: staged and edited again; b.txt: staged; new.txt: staged addition; old.txt: staged rename.
|
||||
fx.write("a.txt", "a staged\n");
|
||||
fx.write("b.txt", "b staged\n");
|
||||
fx.write("new.txt", "new\n");
|
||||
fx.git(&["add", "a.txt", "b.txt", "new.txt"]);
|
||||
fx.git(&["mv", "old.txt", "moved.txt"]);
|
||||
fx.write("a.txt", "a on disk\n");
|
||||
|
||||
// Commit a.txt as it is on disk; leave out b.txt, new.txt and the rename.
|
||||
let options = CommitOptions {
|
||||
stage: vec![fx.path("a.txt").into()],
|
||||
exclude: vec![
|
||||
fx.path("b.txt").into(),
|
||||
fx.path("new.txt").into(),
|
||||
fx.path("moved.txt").into(),
|
||||
fx.path("old.txt").into(),
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
repo.commit_with("only a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
|
||||
|
||||
assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt");
|
||||
assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "a on disk");
|
||||
// What was left out is still staged, exactly as before.
|
||||
let mut staged: Vec<String> =
|
||||
fx.git(&["diff", "--cached", "--name-status", "-M"]).lines().map(str::to_string).collect();
|
||||
staged.sort();
|
||||
assert_eq!(staged, ["A\tnew.txt", "M\tb.txt", "R100\told.txt\tmoved.txt"]);
|
||||
assert_eq!(fx.git(&["show", ":b.txt"]), "b staged");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "", "nothing unstaged is left behind");
|
||||
assert!(!fx.dir.join(".git/openidea-partial-index").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_of_staged_version_keeps_the_unstaged_rest() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "1\n");
|
||||
fx.write("b.txt", "1\n");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
fx.write("a.txt", "2\n");
|
||||
fx.write("b.txt", "2\n");
|
||||
fx.git(&["add", "a.txt", "b.txt"]);
|
||||
fx.write("a.txt", "3\n");
|
||||
|
||||
// a.txt committed as staged (its unstaged edit stays), b.txt left out.
|
||||
let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() };
|
||||
repo.commit_with("staged a", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
|
||||
|
||||
assert_eq!(fx.git(&["show", "HEAD:a.txt"]), "2");
|
||||
assert_eq!(fx.git(&["show", "HEAD:b.txt"]), "1");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "b.txt");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt");
|
||||
assert_eq!(fx.read("a.txt"), "3\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commit_with_exclusions_in_a_repository_without_commits() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "a\n");
|
||||
fx.write("b.txt", "b\n");
|
||||
fx.git(&["add", "-A"]);
|
||||
let repo = fx.repo();
|
||||
let options = CommitOptions { exclude: vec![fx.path("b.txt").into()], ..Default::default() };
|
||||
repo.commit_with("first", &options, &|_| Ok(encoding_rs::UTF_8)).unwrap();
|
||||
assert_eq!(fx.git(&["show", "--name-only", "--format=", "HEAD"]), "a.txt");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-status"]), "A\tb.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_checkout_keeps_staged_changes_staged() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("f.txt", "1\n2\n3\n4\n5\n");
|
||||
fx.write("s.txt", "s\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["checkout", "-q", "-b", "other"]);
|
||||
fx.write("f.txt", "1\nother\n3\n4\n5\n");
|
||||
fx.commit_all("other edit");
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
let repo = fx.repo();
|
||||
|
||||
fx.write("f.txt", "1\n2\n3\n4\nlocal\n");
|
||||
fx.write("s.txt", "staged\n");
|
||||
fx.git(&["add", "s.txt"]);
|
||||
let opts = CheckoutOptions { mode: CheckoutMode::Smart, diverged: None };
|
||||
assert_eq!(repo.checkout_with("other", false, &opts).unwrap(), CheckoutOutcome::Done);
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("other"));
|
||||
assert_eq!(fx.read("f.txt"), "1\nother\n3\n4\nlocal\n");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "s.txt", "staged change stays staged");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "f.txt");
|
||||
assert!(repo.stashes().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_reports_untracked_files_it_would_overwrite() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("f.txt", "f\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["checkout", "-q", "-b", "other"]);
|
||||
fx.write("dir/new.txt", "theirs\n");
|
||||
fx.commit_all("add new");
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
fx.write("dir/new.txt", "mine\n");
|
||||
let repo = fx.repo();
|
||||
for mode in [CheckoutMode::Normal, CheckoutMode::Smart] {
|
||||
let opts = CheckoutOptions { mode, diverged: None };
|
||||
let outcome = repo.checkout_with("other", false, &opts).unwrap();
|
||||
assert_eq!(outcome, CheckoutOutcome::UntrackedFiles { files: vec!["dir/new.txt".into()] });
|
||||
assert_eq!(repo.branch_info().unwrap().head.as_deref(), Some("main"));
|
||||
assert_eq!(fx.read("dir/new.txt"), "mine\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reword_keeps_local_changes_and_can_be_undone() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "1\n");
|
||||
let first = fx.commit_all("first");
|
||||
fx.write("a.txt", "2\n");
|
||||
let second = fx.commit_all("second");
|
||||
fx.write("b.txt", "b\n");
|
||||
fx.commit_all("third");
|
||||
// Local changes, staged and not, must survive untouched.
|
||||
fx.write("a.txt", "local\n");
|
||||
fx.write("staged.txt", "s\n");
|
||||
fx.git(&["add", "staged.txt"]);
|
||||
let repo = fx.repo();
|
||||
|
||||
let change = repo.reword(&second, "second, reworded\n\nwith a body").unwrap();
|
||||
assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond, reworded\nfirst");
|
||||
assert_eq!(fx.git(&["log", "-1", "--format=%B", "HEAD~1"]), "second, reworded\n\nwith a body");
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD~2"]), first, "commits before it keep their ids");
|
||||
assert_eq!(fx.git(&["rev-parse", "HEAD"]), change.after);
|
||||
assert_eq!(fx.git(&["symbolic-ref", "--short", "HEAD"]), "main", "the branch moved, not a detached HEAD");
|
||||
assert_eq!(fx.read("a.txt"), "local\n");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt");
|
||||
assert_eq!(fx.git(&["diff", "--name-only"]), "a.txt");
|
||||
|
||||
repo.restore_head(&change.before, &change.after).unwrap();
|
||||
assert_eq!(fx.git(&["log", "--format=%s", "-3"]), "third\nsecond\nfirst");
|
||||
assert_eq!(fx.read("a.txt"), "local\n");
|
||||
assert_eq!(fx.git(&["diff", "--cached", "--name-only"]), "staged.txt");
|
||||
|
||||
// Undo is refused once HEAD has moved.
|
||||
let change = repo.reword("HEAD", "third, reworded").unwrap();
|
||||
fx.git(&["commit", "-q", "--allow-empty", "-m", "later"]);
|
||||
assert!(repo.restore_head(&change.before, &change.after).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reword_refuses_commits_outside_the_current_branch() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("a.txt", "1\n");
|
||||
fx.commit_all("first");
|
||||
fx.git(&["checkout", "-q", "-b", "side"]);
|
||||
fx.write("a.txt", "side\n");
|
||||
let side = fx.commit_all("side");
|
||||
fx.git(&["checkout", "-q", "main"]);
|
||||
assert!(fx.repo().reword(&side, "other").is_err());
|
||||
}
|
||||
@@ -741,11 +741,6 @@ impl Documents {
|
||||
self.with_doc(doc, |st| Ok(st.dirty))
|
||||
}
|
||||
|
||||
/// Path and version for navigation queries, without copying document text.
|
||||
pub fn identity(&self, doc: DocId) -> IdeResult<(PathBuf, u32)> {
|
||||
self.with_doc(doc, |st| Ok((st.path.clone(), st.doc.version())))
|
||||
}
|
||||
|
||||
/// Current generation without copying the document text.
|
||||
pub fn version(&self, doc: DocId) -> IdeResult<u32> {
|
||||
self.with_doc(doc, |st| Ok(st.doc.version()))
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Databases
|
||||
|
||||
The Database tool window (View ▸ Tool Windows ▸ Database) connects to SQLite,
|
||||
PostgreSQL, MySQL/MariaDB, Microsoft SQL Server, ClickHouse and Oracle, plus the
|
||||
wire-compatible products listed in **New ▸ Data Source**. Connections are saved
|
||||
per project in `.openidea/data-sources.toml`; passwords live outside the project
|
||||
(and never in that file).
|
||||
|
||||
## Oracle: Oracle Instant Client
|
||||
|
||||
Oracle support uses the Oracle Call Interface, so the Oracle client libraries
|
||||
must be installed on the machine. OpenIDEA does not bundle them: install the
|
||||
**Oracle Instant Client** for your platform and point the data source at it.
|
||||
|
||||
1. Download the _Basic_ (or _Basic Light_) package of Oracle Instant Client
|
||||
19c or later for your OS from
|
||||
<https://www.oracle.com/database/technologies/instant-client/downloads.html>.
|
||||
2. Unpack it somewhere stable, then, in the data source's **Options** tab, set
|
||||
**Instant Client** to that directory.
|
||||
3. General tab: host, port (1521), and the **service name** (for example
|
||||
`ORCLPDB1`) in the Database field. Connect with the schema's user and its
|
||||
password.
|
||||
|
||||
`java.sql`-style URLs also work in the URL field:
|
||||
|
||||
```
|
||||
oracle://scott:tiger@db.example.com:1521/ORCLPDB1
|
||||
jdbc:oracle:thin:@//db.example.com:1521/ORCLPDB1
|
||||
```
|
||||
|
||||
A TNS descriptor or alias in the Database field is passed to the client as is.
|
||||
|
||||
### Platform notes
|
||||
|
||||
- **macOS** — the _Basic_ ZIP for ARM (Apple silicon) or x86-64 must match the
|
||||
app's architecture. Remove the quarantine flag if macOS blocks the libraries:
|
||||
`xattr -dr com.apple.quarantine /opt/oracle/instantclient_23_5`.
|
||||
- **Windows** — use the 64-bit package; the app is 64-bit. `PATH` does not have
|
||||
to include the directory when the Options tab names it.
|
||||
- **Linux** — install the RPM/DEB or unzip the ZIP; the directory needs the
|
||||
`libclntsh.so` and `libclntshcore.so` libraries (the ZIP's `libaio`-based
|
||||
builds also need the `libaio` package).
|
||||
|
||||
Without a working client, Test Connection fails with a clear message
|
||||
(`DPI-1047: Cannot locate a 64-bit Oracle Client library…`). If the Options tab
|
||||
is empty and `ORACLE_HOME` is not set, the client is looked up on the system
|
||||
search path.
|
||||
|
||||
### What Oracle data sources support
|
||||
|
||||
- Schemas = Oracle users (`ALL_USERS`); system accounts (`SYS`, `SYSTEM`,
|
||||
`XDB`, `MDSYS`…) appear only with **Schemas ▸ Show system**.
|
||||
- Tables, views, columns, indexes, foreign keys, triggers, routines and
|
||||
sequences are read from the `ALL_*` views.
|
||||
- The query console runs SQL and PL/SQL blocks; `BEGIN … END;` blocks (nested
|
||||
`;` included) are sent as one statement, and a lone `/` on its own line ends
|
||||
one as in SQL*Plus.
|
||||
- Explain Plan runs `EXPLAIN PLAN` and reads `PLAN_TABLE` (`DBMS_XPLAN` output
|
||||
is not required).
|
||||
- Table editor (INSERT/UPDATE/DELETE in one transaction), Import/Export and
|
||||
generated SQL use Oracle syntax: `"UPPER_CASE"` identifiers, `FETCH FIRST n
|
||||
ROWS ONLY`, `MODIFY (…)` for column changes.
|
||||
|
||||
Table editing needs a primary key, and the connection must not be read-only.
|
||||
@@ -0,0 +1,63 @@
|
||||
# Kotlin debugging
|
||||
|
||||
Kotlin Application configurations use the official `fwcd/kotlin-debug-adapter`
|
||||
release, installed through Settings > Language Servers or the missing-debugger
|
||||
prompt. A module containing `.kt` files under `src` uses this adapter for both
|
||||
Application launch and Maven/Gradle JDWP attach, including mixed Java/Kotlin
|
||||
modules. Pure Java modules keep `java-debug`.
|
||||
|
||||
The adapter runs on the language-server JDK (11+). The application runs on its
|
||||
resolved project JDK, with the classpath, VM arguments, program arguments and
|
||||
environment from its run configuration.
|
||||
|
||||
## Source paths
|
||||
|
||||
The adapter infers JVM class names from source paths. OpenIDEA creates a temporary,
|
||||
package-aligned source mirror outside the project and supplies it as the adapter's
|
||||
`projectRoot`. For example, `src/main/kotlin/Main.kt` declaring `package org.example`
|
||||
gets an external `src/main/kotlin/org/example/Main.kt` alias. Breakpoints go through
|
||||
that alias; stack frames and event paths map back to the original editor file.
|
||||
Source sets have separate mirror roots. Where file symlinks are unavailable,
|
||||
OpenIDEA copies sources and refreshes the copies on editor saves.
|
||||
|
||||
Restart debugging after changing a package or source root. The upstream adapter's
|
||||
path-based resolver does not support renamed Kotlin facades (`@file:JvmName`), or
|
||||
packages containing a literal `java` or `kotlin` component. Selecting the correct
|
||||
main class does not remove those breakpoint limitations.
|
||||
|
||||
The Kotlin Application launcher currently passes the classpath directly, without
|
||||
Java's large-command-line shortening, and decodes application output as UTF-8.
|
||||
Very large classpaths and non-UTF-8 application output remain limitations; Windows
|
||||
runtime behavior has not been verified.
|
||||
|
||||
## Startup ordering
|
||||
|
||||
OpenIDEA starts Kotlin applications with JDWP `suspend=y` and attaches the adapter.
|
||||
It waits for each `setBreakpoints` response before sending `configurationDone`.
|
||||
Adapter 0.4.4 never completes the `configurationDone` response, so OpenIDEA sends
|
||||
that request without awaiting its response.
|
||||
|
||||
The official adapter can resume the VMStart event before installing its breakpoint
|
||||
hooks and DAP listeners. A session-owned loopback JDWP relay holds VM and thread
|
||||
resume commands until the successful DAP attach response, which follows listener
|
||||
installation. Other JDWP requests and replies continue during setup. Attach failure
|
||||
or session shutdown closes the relay and kills any JVM launched by OpenIDEA. No
|
||||
adapter patch or timed startup sleep is used. An already-running external JVM must
|
||||
be started with `suspend=y` to preserve its earliest breakpoints.
|
||||
|
||||
## Verification
|
||||
|
||||
`cargo test -p ide-dap --lib` covers relay ordering, fragmented packets, cleanup,
|
||||
source mapping, and the adapter's missing configuration response. The ignored
|
||||
`kotlin_official_release_ten_no_delay_runs` test exercises the real DAP client ten
|
||||
times with an official adapter and a compiled reporter fixture. Set
|
||||
`KOTLIN_PROBE_CONFIG` to a LaunchConfig JSON file and `KOTLIN_PROBE_ADAPTER` to the
|
||||
release executable, then run:
|
||||
|
||||
```sh
|
||||
cargo test -p ide-dap kotlin_official_release_ten_no_delay_runs -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The fixture is `src/main/kotlin/Main.kt`, with `package org.example`, a `for (i in
|
||||
0..3)` loop at line 5 and `println(i)` at line 6, without any startup delay. Each run
|
||||
must hit line 6, expose `i = 0`, step to line 5, and stop its session.
|
||||
@@ -1,54 +0,0 @@
|
||||
# Background document indexers (API 1.2)
|
||||
|
||||
Use `contributes.providers` for interactive definition, completion and line-marker
|
||||
requests about one document. A document indexer is for cross-file associations
|
||||
that need the whole project: it publishes keys once per document, and the host
|
||||
resolves clicks and gutter markers from memory, without scanning files or
|
||||
calling the plugin.
|
||||
|
||||
```toml
|
||||
[[contributes.documentIndexers]]
|
||||
extensions = ["java", "xml"]
|
||||
```
|
||||
|
||||
Implement `openidea_plugin::indexing::DocumentIndexer` and register with
|
||||
`register_plugin!(Plugin, indexing)` (or `register_plugin!(Plugin, providers, indexing)`
|
||||
when the plugin also has interactive providers). Components built with other
|
||||
tools target the `plugin-indexing` world. Indexers require `project.read` and run
|
||||
only in trusted projects.
|
||||
|
||||
`index` gets the document's text (the editor's while it is open) and the symbols
|
||||
the host's syntax support found in it, and returns:
|
||||
|
||||
- `definitions`: where keys are declared;
|
||||
- `references`: ranges that go to the definitions of their key on Go to
|
||||
Declaration and Ctrl/⌘-click. Elsewhere the language server answers as usual;
|
||||
- `aliases`: a `prefix#` whose keys also resolve under `target#`, for inherited
|
||||
members. Chains are followed with bounded depth and candidates;
|
||||
- `markers`: gutter icons that go to the definitions of their key.
|
||||
|
||||
Keys are the plugin's own strings and ranges are UTF-16. When a key has
|
||||
definitions in the same detected module, other modules' definitions are left
|
||||
out. Several targets open a chooser.
|
||||
|
||||
Initial discovery reuses the host's project index. The editor schedules
|
||||
cancellable batches of at most 16 files; edits and file changes invalidate the
|
||||
affected entries before they are indexed again. Reloading, disabling or a
|
||||
failing runtime drops the plugin's graph, and nothing is answered from a
|
||||
document version older than the editor's.
|
||||
|
||||
Limits: 2 MiB per document, 20,000 files per graph, 4,096 definitions and
|
||||
references and 4,096 markers per document, 256 aliases, 200,000 entries per
|
||||
graph, 16 project/window graphs and 256 candidates per lookup. A document over
|
||||
a limit is not indexed; editing is never blocked.
|
||||
|
||||
## Minimal fixture
|
||||
|
||||
`crates/ide-plugin/tests/fixtures/indexer` uses a tiny line-oriented `.idx`
|
||||
language to exercise definitions, references, aliases, markers and host syntax
|
||||
symbols without framework-specific parsing. Run
|
||||
`cargo test -p ide-host --test plugin_indexing` for the real-component contract
|
||||
checks.
|
||||
|
||||
Framework plugins (MyBatis mappers, Rails routes) belong in their authors'
|
||||
repositories and are listed through the registry; see `plugins/README.md`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Unity projects
|
||||
|
||||
Open the folder containing `Assets` and `ProjectSettings/ProjectVersion.txt`.
|
||||
OpenIDEA detects Unity without requiring a generated solution and shows the editor
|
||||
version in the Unity project view.
|
||||
|
||||
The Unity view hides `.meta` files, generated C# project files and cache/build
|
||||
folders. **Show Meta Files** and **Show Generated Files** reveal them independently;
|
||||
the regular Project view is still available. Generated folders are excluded from
|
||||
indexing and search, including folders Unity creates after the project is opened.
|
||||
|
||||
Operations on assets inside `Assets` include their `.meta` files. Rename and move
|
||||
preserve GUIDs, deletion removes both files, and copy generates new GUIDs and updates
|
||||
references within the copied group. References to assets outside the group stay
|
||||
unchanged. File move undo and transactional workspace edits include metadata too.
|
||||
Destination metadata conflicts are rejected before making changes.
|
||||
|
||||
Projects may be opened through a linked root or parent directory, including macOS
|
||||
`/var` and `/tmp`. Links inside the project (including Windows junctions) are not
|
||||
followed by asset operations. For moves/copies to or from ordinary directories,
|
||||
the external path must have no linked ancestors. Cross-volume asset moves and
|
||||
copies that require rewriting binary serialized assets are rejected; use Unity
|
||||
for those operations.
|
||||
@@ -0,0 +1,47 @@
|
||||
# Workspaces and project window groups
|
||||
|
||||
A workspace opens several local project folders in one IDE session. A window group switches between independent IDE sessions; a member can itself contain a workspace.
|
||||
|
||||
## Workspace
|
||||
|
||||
Use **File → New Workspace…**. Choose a name and a separate directory for the workspace, then add project folders. **File → Open Workspace…** opens that directory again. **File → Manage Workspace…** changes its name or linked projects. Removing a link leaves the project's files and open editor buffers intact.
|
||||
|
||||
When opening another local project, choose **Attach** to add it to this window. A plain project becomes a workspace automatically under the IDE app data directory's `workspaces` folder, named after the current project (with a unique directory suffix when needed). The existing editor session carries over after the unsaved-changes guard. **Don't ask again** remembers Attach; change it in **Settings → System Settings → Project → Open project in**. Already linked projects are revealed in Project view. Right-click a non-first linked root and choose **Remove from Workspace** to unlink it without deleting files.
|
||||
|
||||
The workspace stores its manifest in `.openidea/workspace.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"name": "Full stack",
|
||||
"projects": ["/work/frontend", "/work/backend"]
|
||||
}
|
||||
```
|
||||
|
||||
Paths in an existing manifest may be relative to the workspace directory. New and updated manifests use canonical absolute paths. Missing previously linked folders are retained so they can be reconnected or removed. Nested workspace manifests are rejected.
|
||||
|
||||
Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration.
|
||||
|
||||
The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. To link independent Gradle builds in one Gradle tree, open their parent folder as an ordinary project instead of creating a Workspace manifest, then use **Link Gradle Projects…** in the Gradle toolbar or Find Action.
|
||||
|
||||
## Linked Gradle builds in a project
|
||||
|
||||
In an ordinary project window, **Link Gradle Projects…** accepts one or more `settings.gradle`, `settings.gradle.kts`, `build.gradle`, or `build.gradle.kts` files. Each linked build has its own root in the Gradle tool window. Its tasks, dependencies, wrapper, working directory and sync cache stay separate, even when another build has the same `:app` project or task names. Included builds remain under the build that includes them.
|
||||
|
||||
Right-click a build root for **Reload Gradle Project** or **Unlink Gradle Project**. Unlinking only removes the IDE connection; it does not delete files or saved run configurations. The Link button remains available after the last build is unlinked. Linked builds may live outside the opened folder.
|
||||
|
||||
Links are saved in project settings as `languages.java.gradle.linkedProjects`. Until links are configured, existing automatic discovery is unchanged. An explicit empty list means that no Gradle builds are linked. Opening the project again restores its links. A folder first opened by an older version may need one fresh sync because independent builds now have separate caches.
|
||||
|
||||
## Window group
|
||||
|
||||
Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux.
|
||||
|
||||
Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled.
|
||||
|
||||
The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. Hidden members are kept at the selected window's frame and display (and follow it after a move or resize), so switching projects only changes which window is visible. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members.
|
||||
|
||||
## Validation
|
||||
|
||||
Automated coverage includes workspace manifest validation and persistence, missing members, independent language-server initialization roots, cross-root search/repositories/run directories, document preservation after unlinking, window-group membership/order, dialog failures and tab keyboard controls.
|
||||
|
||||
Tauri MCP validation on macOS covered workspace creation and management, opening same-named files from both projects, cross-project search, running both projects, tab selection/reordering/detachment, unsaved close cancellation, full-screen switching and application restart restoration. Windows/Linux native-window behavior has not been visually tested.
|
||||
@@ -47,16 +47,11 @@ const REPLY = [
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
async function openReply(page: Page, text: string, imageData?: string) {
|
||||
async function openReply(page: Page, text: string) {
|
||||
await page.goto("/");
|
||||
await page.getByRole("tree", { name: "Project" }).waitFor();
|
||||
await page.evaluate(
|
||||
async ({ task, text, imageData }) => {
|
||||
if (imageData) {
|
||||
const routePath = "/src/platform/tauriCore.ts";
|
||||
const { setInvokeRoute } = await import(routePath);
|
||||
setInvokeRoute((cmd: string) => (cmd === "fs_read_image" ? Promise.resolve(imageData) : null));
|
||||
}
|
||||
async ({ task, text }) => {
|
||||
// Paths go through variables: knip can't resolve these browser-absolute URLs.
|
||||
const storePath = "/src/features/agents/store.ts";
|
||||
const store = await import(storePath);
|
||||
@@ -71,7 +66,7 @@ async function openReply(page: Page, text: string, imageData?: string) {
|
||||
});
|
||||
store.dockTask(task.id);
|
||||
},
|
||||
{ task: TASK, text, imageData },
|
||||
{ task: TASK, text },
|
||||
);
|
||||
const reply = page.locator("[class*='_markdown_']").first();
|
||||
await reply.locator("h1, p").first().waitFor();
|
||||
@@ -133,19 +128,3 @@ test("styles quotes, rules, images and task lists", async ({ page }) => {
|
||||
// A checkbox stands in for the bullet.
|
||||
expect(await css(reply, "li:has(> input)", "list-style-type")).toBe("none");
|
||||
});
|
||||
|
||||
test("loads local and web reply images", async ({ page }) => {
|
||||
await page.route("https://images.example.com/code.gif", (route) =>
|
||||
route.fulfill({ contentType: "image/gif", body: Buffer.from(PIXEL.split(",")[1]!, "base64") }),
|
||||
);
|
||||
const reply = await openReply(
|
||||
page,
|
||||
"\n\n",
|
||||
PIXEL,
|
||||
);
|
||||
for (const name of ["Local QR", "Web QR"]) {
|
||||
await expect
|
||||
.poll(() => reply.getByRole("img", { name }).evaluate((img: HTMLImageElement) => img.naturalWidth))
|
||||
.toBe(1);
|
||||
}
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
/** Real CodeMirror mouse/navigation path with a deliberately slow IPC boundary.
|
||||
* Semantic correctness and JVM timings live in the real-JDT configuration harness.
|
||||
*/
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { commands } from "../src/platform/ipc";
|
||||
import { usePlatform } from "../src/platform/platformInfo";
|
||||
import { provideEditorService } from "../src/workbench/contributions";
|
||||
import { lspDoc } from "../src/editor/lsp/lsp";
|
||||
import { navigationExtension } from "../src/editor/lsp/navigation";
|
||||
import { hoverExtension } from "../src/editor/lsp/hover";
|
||||
import { lspTheme } from "../src/editor/lsp/theme";
|
||||
|
||||
Object.assign(window, { __TAURI_INTERNALS__: {} });
|
||||
usePlatform.setState({ os: "macos" });
|
||||
document.body.innerHTML = `<style>
|
||||
body { --link: #215cc8; font: 16px monospace; margin: 32px; } button { margin: 12px; }
|
||||
.cm-editor { border: 1px solid #888; min-height: 160px; font: 18px monospace; }
|
||||
</style><button id="back">Back to configuration</button><button id="edit">Edit Java target</button>
|
||||
<div id="config"></div><div id="java" hidden></div>
|
||||
<output id="requests">0</output><output id="opened"></output>`;
|
||||
const configHost = document.querySelector<HTMLDivElement>("#config")!;
|
||||
const javaHost = document.querySelector<HTMLDivElement>("#java")!;
|
||||
let requests = 0;
|
||||
let hoverRequests = 0;
|
||||
let targetLine = 0;
|
||||
let clickedAt = 0;
|
||||
const latency: number[] = [];
|
||||
commands.lspGoto = async () => {
|
||||
document.querySelector("#requests")!.textContent = String(++requests);
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
return {
|
||||
status: "ok",
|
||||
data: [
|
||||
{
|
||||
path: "/fixture/Settings.java",
|
||||
uri: null,
|
||||
range: { start: { line: targetLine, col: 24 }, end: { line: targetLine, col: 31 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
commands.lspHover = async () => {
|
||||
hoverRequests++;
|
||||
return {
|
||||
status: "ok",
|
||||
data: { contents: { kind: "plaintext", value: "dingtalk.driver-class-name String" }, range: null },
|
||||
};
|
||||
};
|
||||
commands.lspDocumentLinks = async () => ({ status: "ok", data: [] });
|
||||
commands.lspDocumentHighlights = async () => ({ status: "ok", data: [] });
|
||||
commands.cancelRequest = async () => ({ status: "ok", data: null });
|
||||
const java = new EditorView({
|
||||
parent: javaHost,
|
||||
state: EditorState.create({
|
||||
doc: "class Settings { String agentId; }",
|
||||
extensions: [
|
||||
lspDoc.of({ doc: 2, path: "/fixture/Settings.java", language: "java", uiMode: "Java", readOnly: false }),
|
||||
navigationExtension(),
|
||||
],
|
||||
}),
|
||||
});
|
||||
const config = new EditorView({
|
||||
parent: configHost,
|
||||
state: EditorState.create({
|
||||
doc: "dingtalk:\n driver-class-name: demo\n",
|
||||
extensions: [
|
||||
EditorState.languageData.of(() => [{ wordChars: "-" }]),
|
||||
lspDoc.of({ doc: 1, path: "/fixture/application.yml", language: "yaml", uiMode: "YAML", readOnly: false }),
|
||||
navigationExtension(),
|
||||
hoverExtension(),
|
||||
lspTheme,
|
||||
],
|
||||
}),
|
||||
});
|
||||
config.contentDOM.addEventListener(
|
||||
"mousedown",
|
||||
() => {
|
||||
clickedAt = performance.now();
|
||||
},
|
||||
true,
|
||||
);
|
||||
provideEditorService({
|
||||
openFile: async (path, options) => {
|
||||
javaHost.hidden = false;
|
||||
configHost.hidden = true;
|
||||
java.dispatch({ selection: { anchor: java.state.doc.line((options?.selection?.line ?? 0) + 1).from + 24 } });
|
||||
document.querySelector("#opened")!.textContent = `${path}:${options?.selection?.line}`;
|
||||
latency.push(performance.now() - clickedAt);
|
||||
},
|
||||
} as Parameters<typeof provideEditorService>[0]);
|
||||
document.querySelector("#back")!.addEventListener("click", () => {
|
||||
javaHost.hidden = true;
|
||||
configHost.hidden = false;
|
||||
});
|
||||
document.querySelector("#edit")!.addEventListener("click", () => {
|
||||
java.dispatch({ changes: { from: 0, insert: "// moved\n" } });
|
||||
targetLine++;
|
||||
});
|
||||
Object.assign(window, { navigationMetrics: () => ({ requests, hoverRequests, latency, injectedServerDelayMs: 500 }) });
|
||||
@@ -1,10 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Configuration navigation fixture</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./configurationNavigation.fixture.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,91 +0,0 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
|
||||
async function keyPosition(page: Page) {
|
||||
const line = page.locator("#config .cm-line").filter({ hasText: "driver-class-name" });
|
||||
const box = (await line.boundingBox())!;
|
||||
// Skip the two-space indentation, land within the actual key.
|
||||
return { x: box.x + 45, y: box.y + box.height / 2 };
|
||||
}
|
||||
async function open(page: Page) {
|
||||
await page.addInitScript(() => Object.defineProperty(Navigator.prototype, "platform", { get: () => "MacIntel" }));
|
||||
await page.goto("/e2e/configurationNavigation.html");
|
||||
await expect(page.locator("#config .cm-content")).toContainText("driver-class-name");
|
||||
}
|
||||
async function metrics(page: Page) {
|
||||
return page.evaluate(() =>
|
||||
(
|
||||
window as unknown as {
|
||||
navigationMetrics: () => {
|
||||
requests: number;
|
||||
hoverRequests: number;
|
||||
latency: number[];
|
||||
injectedServerDelayMs: number;
|
||||
};
|
||||
}
|
||||
).navigationMetrics(),
|
||||
);
|
||||
}
|
||||
|
||||
test("modifier hover and repeated clicks share results; editing Java invalidates them", async ({ page }, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (e) => errors.push(e.message));
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveText("driver-class-name");
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveCSS("cursor", "pointer");
|
||||
await expect(page.locator(".cm-lsp-link")).toHaveCSS("color", "rgb(33, 92, 200)");
|
||||
expect((await metrics(page)).hoverRequests).toBe(0);
|
||||
await expect(page.locator(".cm-lsp-nav-info")).toHaveCount(0);
|
||||
await page.screenshot({ path: info.outputPath("configuration-link.png") });
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0");
|
||||
await page.keyboard.up("Meta");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await page.getByRole("button", { name: "Back to configuration" }).click();
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#java")).toBeVisible();
|
||||
await page.keyboard.up("Meta");
|
||||
}
|
||||
const warm = await metrics(page);
|
||||
expect(warm.requests).toBe(1);
|
||||
expect(warm.latency).toHaveLength(11);
|
||||
const evidence = info.outputPath("warm-navigation.json");
|
||||
await writeFile(evidence, JSON.stringify(warm, null, 2));
|
||||
await info.attach("warm-navigation.json", { path: evidence, contentType: "application/json" });
|
||||
await page.getByRole("button", { name: "Edit Java target" }).click();
|
||||
await page.getByRole("button", { name: "Back to configuration" }).click();
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:1");
|
||||
expect((await metrics(page)).requests).toBe(2);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("clicking before the hover query completes shares the in-flight request", async ({ page }, info) => {
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.keyboard.down("Meta");
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator("#requests")).toHaveText("1");
|
||||
await page.mouse.click(key.x, key.y);
|
||||
await expect(page.locator("#opened")).toHaveText("/fixture/Settings.java:0");
|
||||
const result = await metrics(page);
|
||||
expect(result.requests).toBe(1);
|
||||
await info.attach("inflight-navigation.json", {
|
||||
body: JSON.stringify(result, null, 2),
|
||||
contentType: "application/json",
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary hover still shows configuration documentation", async ({ page }) => {
|
||||
await open(page);
|
||||
const key = await keyPosition(page);
|
||||
await page.mouse.move(key.x, key.y);
|
||||
await expect(page.locator(".cm-lsp-hover")).toContainText("dingtalk.driver-class-name String");
|
||||
expect((await metrics(page)).requests).toBe(0);
|
||||
expect((await metrics(page)).hoverRequests).toBeGreaterThan(0);
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("configures an Oracle source with its Instant Client directory", async ({ workbench: page }, info) => {
|
||||
await page.evaluate(async () => {
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const bindingsPath = "/src/bindings.ts";
|
||||
const { useDatabase } = await import(storePath);
|
||||
const { commands } = await import(bindingsPath);
|
||||
commands.dbTest = async (source: unknown) => {
|
||||
document.documentElement.dataset.testedSource = JSON.stringify(source);
|
||||
return { status: "ok", data: "Oracle Database 23ai Enterprise Edition Release 23.5.0.24.07" };
|
||||
};
|
||||
useDatabase.setState({ dialog: { kind: "source", source: null } });
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true });
|
||||
await dialog.getByRole("button", { name: "Data source type", exact: true }).click();
|
||||
// The type list is virtualised: reach the entry by typing, then confirm it.
|
||||
await page.keyboard.type("oracle");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("1521");
|
||||
await expect(dialog.locator("#db-url")).toHaveValue("jdbc:oracle://localhost:1521");
|
||||
|
||||
// The client libraries are not bundled: the Options tab names their directory (empty: ORACLE_HOME).
|
||||
const testButton = dialog.getByRole("button", { name: "Test Connection", exact: true });
|
||||
await dialog.locator("#db-url").fill("oracle://scott:[email protected]:1521/ORCLPDB1");
|
||||
await expect(dialog.locator("#db-host")).toHaveValue("db.example");
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("1521");
|
||||
await expect(dialog.locator("#db-user")).toHaveValue("scott");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("ORCLPDB1");
|
||||
await expect(testButton).toBeEnabled();
|
||||
|
||||
await dialog.getByRole("tab", { name: "Options", exact: true }).click();
|
||||
await dialog.locator("#db-instant-client").fill("/opt/oracle/instantclient_23_5");
|
||||
await testButton.click();
|
||||
await expect(dialog.getByRole("status")).toHaveText(/^Succeeded: Oracle Database 23ai/);
|
||||
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({
|
||||
driver: "oracle",
|
||||
host: "db.example",
|
||||
port: 1521,
|
||||
user: "scott",
|
||||
database: "ORCLPDB1",
|
||||
options: { instantClient: "/opt/oracle/instantclient_23_5" },
|
||||
});
|
||||
await dialog.screenshot({ path: info.outputPath("oracle-source-options.png") });
|
||||
});
|
||||
@@ -52,27 +52,12 @@ modules, skips nested independent modules and build output, and honors ignore
|
||||
files for disk discovery. It does not guess a global association from matching
|
||||
text in unrelated projects.
|
||||
|
||||
Trusted projects with a Maven/Gradle/Eclipse build file at the root start Java a
|
||||
moment after opening, even without a Java editor open. Once JDT reports
|
||||
ServiceReady and compiler defaults are applied, the app asks the extension to warm
|
||||
the configuration index (`openidea.java.configuration.warmup`). A low-priority
|
||||
system job waits for workspace builds, then builds the same snapshot navigation
|
||||
uses, one Java project at a time: modules with open YAML/properties files first,
|
||||
then modules with open Java editors. At most 64 modules are queued, each attempt
|
||||
has a cooperative 30-second budget, repeated readiness does not repeat finished
|
||||
work, and warming only fills free room within the retention limits below (it never
|
||||
evicts an index a lookup built). A snapshot outdated while it was being built is
|
||||
dropped. Untrusted projects do not start Java; anything warming skips keeps
|
||||
on-demand indexing.
|
||||
|
||||
Otherwise indexing is lazy. The first explicit navigation/search builds a compact snapshot;
|
||||
Indexing is lazy. The first explicit navigation/search builds a compact snapshot;
|
||||
ordinary typing, code-lens probes, bulk scoped reference analysis, and Java Go to Definition / Ctrl+hover outside a `@Value` or property-lookup argument (checked with the bundled tree-sitter grammar) do not start indexing. At most eight project snapshots are
|
||||
retained, with a shared 100,000-association retention cap. Records contain property names, element handles and source locations;
|
||||
ASTs and bindings are discarded after indexing. A lexical prefilter avoids AST
|
||||
construction for ordinary Java units, but every accepted association is resolved
|
||||
semantically. Builds hold a per-project lock that a waiting lookup can cancel, so a
|
||||
background build of one project never delays another, and a lookup in the same
|
||||
project reuses the result. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
semantically. Warm requests reuse the snapshot. Java model changes (sources on
|
||||
disk, classpath, archives) invalidate it conservatively, including dependent-type
|
||||
changes; a fingerprint of working copies with unsaved changes covers edits before
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
@@ -80,14 +65,6 @@ resource changes (configuration files are parsed by the client; metadata files a
|
||||
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
|
||||
incremental Java semantic index.
|
||||
|
||||
In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots
|
||||
included, and doesn't open the documentation popup. Hover and Go to Definition share
|
||||
one request per key: a click after the hover reuses its answer. The editor keeps up
|
||||
to 128 answers / 4,096 locations, drops them on edits, file and project changes,
|
||||
workspace edits and language-server restarts, and expires them after 30 seconds as a
|
||||
backstop. Empty answers and errors are not kept, since the import may still be in
|
||||
progress.
|
||||
|
||||
Metadata/JAR timestamps are checked before reuse. The client caches parsed disk
|
||||
configuration files by modification time and size (at most 100,000 cached keys), overlays open buffers, and
|
||||
performs bounded discovery/parsing on a blocking worker. It does not continuously
|
||||
@@ -97,7 +74,7 @@ Bounds: 20,000 Java units, 100,000 associations per snapshot, 2 MiB per candidat
|
||||
metadata/configuration file (Java source: 2M UTF-16 units), 1,024-character property names, 2,000 configuration files/results, 50,000 filesystem
|
||||
entries per root, and depth limits on recursive bindings/YAML. Java requests and
|
||||
references are cancellable. File/association scan limits and unreadable files carry the
|
||||
existing incomplete-result indicator; unsupported syntax keys are omitted. Navigation before warming finishes can take seconds on a
|
||||
existing incomplete-result indicator; unsupported syntax keys are omitted. First navigation can take seconds on a
|
||||
cold project; classpath import and source download remain owned by JDT LS.
|
||||
|
||||
## Deliberate limits
|
||||
@@ -128,8 +105,7 @@ cargo test -p ide-language -p ide-lsp
|
||||
The isolated real-server harness covers custom bindings, inheritance, nesting,
|
||||
collections/maps, records, constructor aliases, factory methods, consumers,
|
||||
constant keys, negative identity matches, dependency metadata/source locations,
|
||||
reverse queries, warming (queued once, reused by the first lookup, cancellable), and
|
||||
unsaved-change/close invalidation. Optional stress fixtures
|
||||
reverse queries, and unsaved-change/close invalidation. Optional stress fixtures
|
||||
add 3,000 ordinary units and 100 binding classes; output includes cold/warm/edit
|
||||
latencies for the JDT command only, excluding server import, UI and configuration-file discovery. The optional actual Spring Boot JAR checks `server.port`,
|
||||
`spring.datasource.url` and `spring.jackson.date-format` through dependency
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.6.0
|
||||
Bundle-Version: 1.5.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -57,7 +57,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.6.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.5.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -83,10 +83,6 @@ Version 1.5.0 answers `{locations, complete}`: `complete` when the type is decla
|
||||
dependencies' implementations after the first ones. The search also covers the project's Kotlin
|
||||
classes attached by the Kotlin bridge; its reproducible SHA-256 is
|
||||
`7588748c2797f9da2beb209714a75c29b4e31b155f095329bfb7e399d807c20a`.
|
||||
Version 1.6.0 adds `openidea.java.configuration.warmup`, which builds configuration indexes in a
|
||||
low-priority background job after import, and builds them under cancellable per-project locks
|
||||
instead of one global lock; its reproducible SHA-256 is
|
||||
`33cfa1bb8489247ffeb6fc8249966c8a2955b7b2858c8169a70486eb70ade696`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Binary file not shown.
@@ -6,7 +6,6 @@
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.configuration.ConfigurationCommand">
|
||||
<command id="openidea.java.configuration"/>
|
||||
<command id="openidea.java.configuration.warmup"/>
|
||||
</delegateCommandHandler>
|
||||
<delegateCommandHandler class="ai.openidea.implementations.ImplementationsCommand">
|
||||
<command id="openidea.java.implementations"/>
|
||||
|
||||
@@ -4,10 +4,7 @@ import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.zip.ZipFile;
|
||||
import com.google.gson.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
@@ -32,8 +29,6 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
private static final int MAX_UNITS = 20_000, MAX_ENTRIES = 100_000, MAX_BYTES = 2 * 1024 * 1024;
|
||||
private static final AtomicLong REVISION = new AtomicLong(), GENERATION = new AtomicLong();
|
||||
private static final Map<String, Index> CACHE = new LinkedHashMap<>();
|
||||
/** One build per project at a time; waiting lookups stay cancellable. */
|
||||
private static final Map<String, ReentrantLock> LOCKS = new ConcurrentHashMap<>();
|
||||
/** Unsaved edits are covered by the working-copy digest, so reconciles never
|
||||
* invalidate. Opening/closing a working copy (F_PRIMARY_WORKING_COPY) and
|
||||
* non-Java resources, reported as F_CONTENT on a project, root or package
|
||||
@@ -61,7 +56,6 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
|
||||
@Override
|
||||
public Object executeCommand(String command, List<Object> args, IProgressMonitor monitor) throws Exception {
|
||||
if (command.equals("openidea.java.configuration.warmup")) return ConfigurationWarmup.request(args);
|
||||
String uri = (String) args.get(0);
|
||||
String key = args.get(1) instanceof String s ? s : null;
|
||||
int line = ((Number) args.get(2)).intValue(), column = ((Number) args.get(3)).intValue();
|
||||
@@ -144,7 +138,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
(line < r.getEnd().getLine() || line == r.getEnd().getLine() && col < r.getEnd().getCharacter());
|
||||
}
|
||||
private static void check(IProgressMonitor monitor) { if (monitor.isCanceled()) throw new OperationCanceledException(); }
|
||||
static IJavaProject project(String uri) {
|
||||
private static IJavaProject project(String uri) {
|
||||
Path path;
|
||||
try { path = Path.of(URI.create(uri)).toFile().getCanonicalFile().toPath(); } catch (Exception e) { return null; }
|
||||
IJavaProject best = null;
|
||||
@@ -169,33 +163,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Builds (or reuses) the project's index in the background; its generation. */
|
||||
static long warm(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
return index(project, monitor, true).generation;
|
||||
}
|
||||
|
||||
/** Warming fills free cache room only; it never evicts an index a lookup built. */
|
||||
static boolean hasWarmupCapacity(IJavaProject project) {
|
||||
synchronized (CACHE) {
|
||||
return CACHE.containsKey(project.getHandleIdentifier()) || (CACHE.size() < 8 &&
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() < MAX_ENTRIES);
|
||||
}
|
||||
}
|
||||
|
||||
private static Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
return index(project, monitor, false);
|
||||
}
|
||||
|
||||
private static Index index(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception {
|
||||
ReentrantLock lock = LOCKS.computeIfAbsent(project.getHandleIdentifier(), ignored -> new ReentrantLock());
|
||||
while (!lock.tryLock(50, TimeUnit.MILLISECONDS)) check(monitor);
|
||||
try {
|
||||
check(monitor);
|
||||
return build(project, monitor, background);
|
||||
} finally { lock.unlock(); }
|
||||
}
|
||||
|
||||
private static Index build(IJavaProject project, IProgressMonitor monitor, boolean background) throws Exception {
|
||||
private static synchronized Index index(IJavaProject project, IProgressMonitor monitor) throws Exception {
|
||||
long revision = REVISION.get();
|
||||
String id = project.getHandleIdentifier();
|
||||
Map<String, ICompilationUnit> copies = new TreeMap<>();
|
||||
@@ -213,8 +181,7 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
if (source != null) digest.update(source.getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
}
|
||||
String workingCopies = HexFormat.of().formatHex(digest.digest());
|
||||
Index cached;
|
||||
synchronized (CACHE) { cached = CACHE.get(id); }
|
||||
Index cached = CACHE.get(id);
|
||||
if (cached != null && cached.revision == revision && cached.workingCopies.equals(workingCopies)) {
|
||||
boolean valid = true;
|
||||
for (var entry : cached.files.entrySet()) {
|
||||
@@ -260,19 +227,11 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
roots.add(project.getProject().getLocationURI().toString());
|
||||
builder.metadataDirectory(project.getProject().getLocation().toFile().toPath().resolve("src/main/resources"), project);
|
||||
Index result = new Index(revision, workingCopies, List.copyOf(builder.entries), List.copyOf(roots), Map.copyOf(builder.files), builder.incomplete, builder.parsed, GENERATION.incrementAndGet());
|
||||
check(monitor);
|
||||
// A warmed index that the model has already outdated is dropped, not published.
|
||||
if (background && revision != REVISION.get()) throw new OperationCanceledException();
|
||||
synchronized (CACHE) {
|
||||
if (background && ((!CACHE.containsKey(id) && CACHE.size() >= 8) ||
|
||||
CACHE.entrySet().stream().filter(e -> !e.getKey().equals(id)).mapToInt(e -> e.getValue().entries.size()).sum()
|
||||
+ result.entries.size() > MAX_ENTRIES)) throw new OperationCanceledException();
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
}
|
||||
CACHE.remove(id);
|
||||
while (!CACHE.isEmpty() && (CACHE.size() >= 8 ||
|
||||
CACHE.values().stream().mapToInt(value -> value.entries.size()).sum() + result.entries.size() > MAX_ENTRIES))
|
||||
CACHE.remove(CACHE.keySet().iterator().next());
|
||||
CACHE.put(id, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
package ai.openidea.configuration;
|
||||
|
||||
import java.util.*;
|
||||
import org.eclipse.core.resources.ResourcesPlugin;
|
||||
import org.eclipse.core.runtime.*;
|
||||
import org.eclipse.core.runtime.jobs.Job;
|
||||
import org.eclipse.jdt.core.*;
|
||||
|
||||
/** One bounded, cooperative queue per language-server process. No polling timer,
|
||||
* retained ASTs, or project builds; an ordinary lookup remains the fallback. */
|
||||
final class ConfigurationWarmup {
|
||||
private static final Map<String, IJavaProject> pending = new LinkedHashMap<>();
|
||||
private static final Set<String> attempted = new HashSet<>();
|
||||
private static final Map<String, Long> completed = new LinkedHashMap<>();
|
||||
private static boolean scheduled, stopped;
|
||||
private static String active;
|
||||
private static final Job job = new Job("Preparing configuration navigation") {
|
||||
@Override protected IStatus run(IProgressMonitor monitor) {
|
||||
// Compiler defaults/import can schedule a build after ServiceReady.
|
||||
// Yield to it instead of blocking a worker on the build job family.
|
||||
if (building()) {
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (!stopped) schedule(2000);
|
||||
else scheduled = false;
|
||||
}
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
IJavaProject project;
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (stopped || pending.isEmpty()) { scheduled = false; return Status.OK_STATUS; }
|
||||
String id = pending.keySet().iterator().next();
|
||||
project = pending.remove(id);
|
||||
active = id;
|
||||
}
|
||||
long deadline = System.nanoTime() + 30_000_000_000L;
|
||||
IProgressMonitor bounded = new ProgressMonitorWrapper(monitor) {
|
||||
@Override public boolean isCanceled() {
|
||||
return super.isCanceled() || System.nanoTime() >= deadline ||
|
||||
!project.exists() || !project.getProject().isOpen();
|
||||
}
|
||||
};
|
||||
try {
|
||||
if (ConfigurationCommand.hasWarmupCapacity(project)) {
|
||||
long build = ConfigurationCommand.warm(project, bounded);
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
if (completed.size() < 8) completed.put(project.getHandleIdentifier(), build);
|
||||
}
|
||||
}
|
||||
} catch (OperationCanceledException e) {
|
||||
// No partial index is published. A user lookup can retry later.
|
||||
} catch (Exception e) {
|
||||
org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.logException("Configuration warmup skipped", e);
|
||||
} finally {
|
||||
synchronized (ConfigurationWarmup.class) {
|
||||
active = null;
|
||||
if (!stopped && !pending.isEmpty()) schedule(200);
|
||||
else scheduled = false;
|
||||
}
|
||||
}
|
||||
return Status.OK_STATUS;
|
||||
}
|
||||
};
|
||||
static { job.setSystem(true); job.setPriority(Job.DECORATE); }
|
||||
|
||||
private static boolean building() {
|
||||
for (Object family : new Object[] { ResourcesPlugin.FAMILY_AUTO_BUILD, ResourcesPlugin.FAMILY_MANUAL_BUILD })
|
||||
for (Job other : Job.getJobManager().find(family))
|
||||
if (other.getState() != Job.NONE) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static synchronized Object request(List<Object> args) throws JavaModelException {
|
||||
String action = args.isEmpty() ? "start" : String.valueOf(args.get(0));
|
||||
if (action.equals("cancel")) {
|
||||
stopped = true;
|
||||
pending.clear();
|
||||
job.cancel();
|
||||
if (active == null) scheduled = false;
|
||||
} else if (action.equals("start") && !stopped) {
|
||||
// Open Java buffers identify the user's active modules. Keep those
|
||||
// ahead of the rest when the workspace exceeds the cache budget.
|
||||
Set<String> open = new HashSet<>();
|
||||
for (ICompilationUnit unit : JavaCore.getWorkingCopies(null)) open.add(unit.getJavaProject().getHandleIdentifier());
|
||||
for (ICompilationUnit unit : JavaCore.getWorkingCopies(
|
||||
org.eclipse.jdt.ls.core.internal.JavaLanguageServerPlugin.getInstance().getWorkingCopyOwner()))
|
||||
open.add(unit.getJavaProject().getHandleIdentifier());
|
||||
Set<String> preferred = new HashSet<>();
|
||||
if (args.size() > 1 && args.get(1) instanceof List<?> uris) {
|
||||
for (Object uri : uris) if (uri instanceof String text) {
|
||||
IJavaProject project = ConfigurationCommand.project(text);
|
||||
if (project != null) preferred.add(project.getHandleIdentifier());
|
||||
}
|
||||
}
|
||||
List<IJavaProject> projects = new ArrayList<>(Arrays.asList(
|
||||
JavaCore.create(ResourcesPlugin.getWorkspace().getRoot()).getJavaProjects()));
|
||||
projects.sort(Comparator.comparing((IJavaProject p) -> !preferred.contains(p.getHandleIdentifier()))
|
||||
.thenComparing(p -> !open.contains(p.getHandleIdentifier()))
|
||||
.thenComparing(IJavaProject::getElementName));
|
||||
for (IJavaProject project : projects) {
|
||||
String id = project.getHandleIdentifier();
|
||||
if (project.getProject().isOpen() && !attempted.contains(id) && pending.size() < 64) {
|
||||
attempted.add(id);
|
||||
pending.put(id, project);
|
||||
}
|
||||
}
|
||||
if (!scheduled && !pending.isEmpty()) { scheduled = true; job.schedule(2000); }
|
||||
}
|
||||
return Map.of("scheduled", scheduled, "pending", pending.size(), "active", active == null ? "" : active,
|
||||
"completed", new LinkedHashMap<>(completed));
|
||||
}
|
||||
}
|
||||
@@ -159,28 +159,11 @@ try:
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didOpen', 'params': {'textDocument': {'uri': file.as_uri(), 'languageId': 'java', 'version': 1, 'text': source}}})
|
||||
request('java/buildWorkspace', True)
|
||||
assert 'openidea.java.configuration' in init['capabilities']['executeCommandProvider']['commands'], init
|
||||
def warmup(action='start'):
|
||||
return request('workspace/executeCommand', {'command': 'openidea.java.configuration.warmup', 'arguments': [action, [(project / 'application.yml').as_uri()]]})
|
||||
# Queuing is cheap and idempotent; the index is built by a background job.
|
||||
queued_at = time.perf_counter()
|
||||
queued = warmup()
|
||||
assert queued['scheduled'] and queued['pending'] > 0 and not queued['completed'], queued
|
||||
assert warmup()['pending'] == queued['pending'], 'a repeated start queued the project twice'
|
||||
deadline = time.monotonic() + 90
|
||||
while time.monotonic() < deadline:
|
||||
warmed = warmup('status')
|
||||
if not warmed['scheduled']: break
|
||||
time.sleep(.1)
|
||||
assert not warmed['scheduled'] and warmed['completed'], warmed
|
||||
assert warmup()['pending'] == 0, 'a repeated start queued a warmed project again'
|
||||
prewarm_seconds = time.perf_counter() - queued_at
|
||||
results = {}
|
||||
for key in ['app', 'vendor', 'app.time-out', 'app.enabled', 'app.nested.url', 'app.clients[0].url', 'app.routes.east.url', 'app.tags[0]', 'app.labels.region', 'app.labels[region.name]', 'constructor.remote-name', 'record.name', 'bean.url', 'app.backup', 'app.required', 'vendor.port', 'extra.url']:
|
||||
start = time.perf_counter()
|
||||
result = query(key)
|
||||
results[key] = {'seconds': time.perf_counter() - start, 'response': result}
|
||||
if key == 'app':
|
||||
assert result['generation'] in warmed['completed'].values(), ('the first lookup rebuilt the warmed index', result, warmed)
|
||||
assert result['locations'], (key, result)
|
||||
attached = request('java/classFileContents', {'uri': results['vendor.port']['response']['locations'][0]['uri']})
|
||||
assert attached and 'LibrarySettings' in attached and 'port' in attached, attached
|
||||
@@ -255,9 +238,7 @@ try:
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didClose', 'params': {'textDocument': {'uri': file.as_uri()}}})
|
||||
restored = query('app.nested.url')
|
||||
assert restored['locations'], restored
|
||||
cancelled = warmup('cancel')
|
||||
assert not cancelled['scheduled'] and cancelled['pending'] == 0, cancelled
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'prewarmSeconds': prewarm_seconds, 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
||||
evidence = {'stress': os.environ.get('OPENIDEA_CONFIGURATION_STRESS') == '1', 'editRebuildMs': edit_ms, 'cases': results, 'warmMedianMs': sorted(warm)[len(warm)//2] * 1000, 'warmMaxMs': max(warm) * 1000, 'generations': [generation, after_save, after_close]}
|
||||
(work / 'evidence.json').write_text(json.dumps(evidence, indent=2))
|
||||
print(json.dumps(evidence, indent=2))
|
||||
finally:
|
||||
|
||||
+13
-34
@@ -1,6 +1,6 @@
|
||||
# Lombok agent for JDT LS
|
||||
|
||||
`lombok-1.18.48-openidea.3.jar` is the app-provided Java agent for JDT LS. It includes:
|
||||
`lombok-1.18.48-openidea.2.jar` is the app-provided Java agent for JDT LS. It includes:
|
||||
|
||||
- [projectlombok/lombok#4059](https://github.com/projectlombok/lombok/pull/4059), which restores
|
||||
`lombok.config` lookup after Equinox removed `getModuleClassLoader(boolean)`.
|
||||
@@ -10,11 +10,6 @@
|
||||
that call in the `@SneakyThrows` try block (ECJ then fails with "Internal Error compiling …
|
||||
BlockScope cannot be cast to MethodScope" and the whole workspace build fails) and put `@NonNull`
|
||||
checks before it; constructors Lombok generates lacked the statement.
|
||||
- [`patches/builder-compact-constructor.patch`](patches/builder-compact-constructor.patch), not yet
|
||||
proposed upstream. `@Builder` on a compact record constructor (`record R(...) { @Builder R {} }`)
|
||||
failed under ECJ ("The method builder() is undefined", missing builder setters), because ECJ has
|
||||
not filled in a compact constructor's implicit parameters when Lombok runs. The builder now takes
|
||||
its parameters from the record components in that case; javac was unaffected.
|
||||
|
||||
The jar is embedded by `ide-tools`, so every platform receives the same bytes without a download.
|
||||
Both Java server startup and launch diagnostics obtain it through `managed_bundle`. Tool detection
|
||||
@@ -24,7 +19,7 @@ The filename includes the patch version because JDT LS workspace keys include th
|
||||
changing agents must rebuild classes produced by the old agent. Old jars remain for running windows.
|
||||
|
||||
Replace this temporary bundle with an official Maven Central release once that release includes
|
||||
#4059, #4089 and the compact-constructor builder fix. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
|
||||
#4059 and #4089. Restore the registry's Maven Central install method and remove the embedded jar. Keep the
|
||||
workspace migration test when changing its path.
|
||||
|
||||
## Provenance and build
|
||||
@@ -38,12 +33,10 @@ workspace migration test when changing its path.
|
||||
- PR #4089 (`Rawi01/lombok` `eclipse-constructor-call-statement`):
|
||||
- `7832faa49b35c839088866daf865384c82cd555f`
|
||||
- `3f3a80b857a969839ab1baba12767aec4965ac32`
|
||||
- Then `git apply` [`patches/builder-compact-constructor.patch`](patches/builder-compact-constructor.patch)
|
||||
(changes `src/core/lombok/eclipse/handlers/HandleBuilder.java` only).
|
||||
- Upstream version: `1.18.48`; `openidea.3` identifies this distribution of the patched agent
|
||||
(`openidea.1` had #4059 only; `openidea.2` added #4089).
|
||||
- Upstream version: `1.18.48`; `openidea.2` identifies this distribution of the patched agent
|
||||
(`openidea.1` had #4059 only).
|
||||
Upstream's own `java -jar ... --version` still reports `1.18.48`.
|
||||
- SHA-256: `26069a8138680f5e1100c83cd064d6aa7314385a1afcef74269cdcc41beb0442`
|
||||
- SHA-256: `2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379`
|
||||
- License: [MIT, including bundled component notices](LICENSE), also retained inside the jar.
|
||||
- Build environment: macOS arm64, Temurin 25.0.5, Apache Ant 1.10.15, upstream Ivy setup
|
||||
(`openidea.1` was built with OpenJDK 23.0.1).
|
||||
@@ -58,9 +51,8 @@ git fetch https://github.com/chagong/lombok.git fix-equinox-bundle-wiring
|
||||
git fetch https://github.com/Rawi01/lombok.git eclipse-constructor-call-statement
|
||||
git cherry-pick 2d535482a913de46c27d06c5b0b294918ede3f9c 3098f4dd78b5349c2f382e7310e64db810dd380b
|
||||
git cherry-pick 7832faa49b35c839088866daf865384c82cd555f 3f3a80b857a969839ab1baba12767aec4965ac32
|
||||
git apply /path/to/openidea/java/lombok/patches/builder-compact-constructor.patch
|
||||
ant dist
|
||||
cp dist/lombok.jar /path/to/openidea/java/lombok/lombok-1.18.48-openidea.3.jar
|
||||
cp dist/lombok.jar /path/to/openidea/java/lombok/lombok-1.18.48-openidea.2.jar
|
||||
```
|
||||
|
||||
The changelog context conflicts because the first commit adds a release-note line under the
|
||||
@@ -70,15 +62,13 @@ skipped). The #4089 commits apply cleanly. The final source diff from v1.18.48 c
|
||||
`src/eclipseAgent/lombok/launch/PatchFixesHider.java`, `test/core/src/lombok/TestEclipse.java`,
|
||||
`test/core/src/lombok/launch/PatchFixesHiderTest.java` (#4059) and the Eclipse handlers
|
||||
`EclipseHandlerUtil`, `HandleConstructor`, `HandleFieldNameConstants`, `HandleNonNull`,
|
||||
`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089)
|
||||
and `HandleBuilder` (the compact-constructor patch); no other Java source changes were made.
|
||||
`openidea.3` was built with the unmodified upstream build scripts. (`openidea.2`'s build checkout
|
||||
reordered the two remote resolvers in `buildScripts/ivysettings.xml` to try Maven Central before
|
||||
Eclipse staging and used a local HTTP/HTTPS proxy for downloads.) Compared with `openidea.2`, the
|
||||
only changed class is `HandleBuilder`; other differences are build timestamps in the generated
|
||||
`META-INF/services` files and the nested `mavenEcjBootstrapAgent.jar`. Ant's timestamps mean a
|
||||
rebuild need not be byte-identical; `SHA256SUMS` records the checked-in artifact and is verified by
|
||||
the Rust regression tests.
|
||||
`HandleSneakyThrows`, `HandleStandardException`, `HandleSuperBuilder`, `HandleUtilityClass` (#4089);
|
||||
no other Java source changes were made.
|
||||
In the build checkout, the two remote resolvers in
|
||||
`buildScripts/ivysettings.xml` were reordered to try Maven Central before Eclipse staging; the
|
||||
local resolver and all dependency coordinates were retained. The JVM was configured to use the
|
||||
local HTTP/HTTPS proxy for downloads. Ant's timestamps mean a rebuild need not be byte-identical;
|
||||
`SHA256SUMS` records the checked-in artifact and is verified by the Rust regression tests.
|
||||
|
||||
## Validation
|
||||
|
||||
@@ -108,17 +98,6 @@ On macOS arm64 with JDT LS 1.63.0 / Equinox 3.25.0, server OpenJDK 25 and projec
|
||||
Failed with three internal errors and wrote no classes; `openidea.2` Succeeded with all classes.
|
||||
- `java/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.2`.
|
||||
|
||||
`openidea.3` (compact-constructor builders) on macOS arm64, JDT LS ECJ
|
||||
`compiler.batch_3.46.200.v20261008-1115`, Temurin 25:
|
||||
|
||||
- [`tests/verify.py`](tests/verify.py) compiles and runs
|
||||
[`tests/BuilderCompactConstructor.java`](tests/BuilderCompactConstructor.java) with javac and with
|
||||
ECJ plus the agent: builders on compact constructors (with validation, `toBuilder`, `setterPrefix`,
|
||||
generics, `@Singular`, `@NonNull`, empty records), a long-form canonical constructor, a
|
||||
non-canonical record constructor, type-level record builders, and the #4089 constructor cases.
|
||||
Both passed with `openidea.3`; with `openidea.2`, ECJ failed on every compact-constructor builder.
|
||||
- `java/java-usages/tests/verify.py` and `read-write.py` passed with `openidea.3`.
|
||||
|
||||
Windows and Linux runtime validation has not been performed. Rust tests cover source selection
|
||||
for both supported architectures on all three OS variants, offline provisioning, corruption
|
||||
repair, concurrent windows, explicit user overrides, and JDT LS workspace migration.
|
||||
@@ -1 +1 @@
|
||||
26069a8138680f5e1100c83cd064d6aa7314385a1afcef74269cdcc41beb0442 lombok-1.18.48-openidea.3.jar
|
||||
2978d8b2d55dac1bb4936ba44562bc4549931d4e71a7ebfeaebccd60bcdf7379 lombok-1.18.48-openidea.2.jar
|
||||
Binary file not shown.
@@ -1,21 +0,0 @@
|
||||
diff --git a/src/core/lombok/eclipse/handlers/HandleBuilder.java b/src/core/lombok/eclipse/handlers/HandleBuilder.java
|
||||
index 64a3fa9..6ebaaaf 100755
|
||||
--- a/src/core/lombok/eclipse/handlers/HandleBuilder.java
|
||||
+++ b/src/core/lombok/eclipse/handlers/HandleBuilder.java
|
||||
@@ -493,10 +493,13 @@ public class HandleBuilder extends EclipseAnnotationHandler<Builder> {
|
||||
if (!checkBuilderClassNameAnnotationConflict("Builder", "lombok.Builder", job.builderClassName, ast, annotationNode)) return;
|
||||
|
||||
if (fillParametersFrom != null) {
|
||||
- for (EclipseNode param : fillParametersFrom.down()) {
|
||||
- if (param.getKind() != Kind.ARGUMENT) continue;
|
||||
+ // ECJ has not populated the implicit arguments of a compact record constructor yet.
|
||||
+ boolean compactConstructor = parent.get() instanceof ConstructorDeclaration
|
||||
+ && isRecord(job.parentType) && (parent.get().bits & IsCanonicalConstructor) != 0;
|
||||
+ for (EclipseNode param : (compactConstructor ? job.parentType : fillParametersFrom).down()) {
|
||||
+ if (compactConstructor ? !isRecordField(param) : param.getKind() != Kind.ARGUMENT) continue;
|
||||
BuilderFieldData bfd = new BuilderFieldData();
|
||||
- Argument arg = (Argument) param.get();
|
||||
+ AbstractVariableDeclaration arg = (AbstractVariableDeclaration) param.get();
|
||||
|
||||
Annotation[] copyableAnnotations = findCopyableAnnotations(param);
|
||||
|
||||
Loaded 100 of 263 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user