mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 17:20:15 -10:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e14395969 | ||
|
|
f5895d0336 | ||
|
|
c3c1d88d10 | ||
|
|
a0ac988963 | ||
|
|
d5c4066748 | ||
|
|
8d784ac164 | ||
|
|
7ffaa0b5da | ||
|
|
ab2b53ff80 | ||
|
|
2ba731f0ec | ||
|
|
742e504719 | ||
|
|
ebe059e00f | ||
|
|
e17e59ce7d | ||
|
|
8021a8ccca | ||
|
|
5a6a6f7e05 | ||
|
|
a7f0f8110b | ||
|
|
9669e58398 | ||
|
|
d939dca303 | ||
|
|
23378e0cc2 | ||
|
|
c83264ae75 | ||
|
|
2fc3535300 | ||
|
|
d96ff8e881 | ||
|
|
3b02e6899d | ||
|
|
948e93e1a2 | ||
|
|
5d19baf06b | ||
|
|
89309e40ba | ||
|
|
9f2e6b570f | ||
|
|
2d20e5a203 | ||
|
|
7c60737b69 | ||
|
|
7e9acdc59b | ||
|
|
2013ddaac2 | ||
|
|
ce898ea0c6 |
No files matched your search
@@ -2,6 +2,10 @@ name: Language smoke
|
||||
# Everyday code intelligence (completion, go to definition, Find Usages, hover, diagnostics
|
||||
# with a quick fix, an intention, formatting, rename) per language, against the language
|
||||
# server the app starts by default: crates/ide-host/tests/language_smoke.rs.
|
||||
# Then running and testing each fixture through the run store and openidea-server
|
||||
# (Run, Run tests, the test tree, Rerun Failed Tests): smoke/run.test.ts; and debugging its
|
||||
# entry point with the debugger the IDE installs (breakpoint, frames, variables, evaluate,
|
||||
# Step Over, Resume): smoke/debug.test.ts.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,12 +18,19 @@ on:
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "src/features/debugger/**"
|
||||
- "src/platform/**"
|
||||
- "smoke/**"
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: language-smoke-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
# A new push supersedes a PR's run; on main every merge runs to the end (and saves the cache).
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -53,9 +64,10 @@ jobs:
|
||||
shared-key: language-smoke
|
||||
save-if: ${{ github.ref == 'refs/heads/main' && matrix.language == 'rust' }}
|
||||
|
||||
- if: matrix.language == 'typescript'
|
||||
uses: actions/setup-node@v4
|
||||
with: { node-version: 22 }
|
||||
- uses: pnpm/action-setup@v4
|
||||
with: { version: 11 }
|
||||
- uses: actions/setup-node@v4
|
||||
with: { node-version: 22, cache: pnpm }
|
||||
- if: matrix.language == 'typescript'
|
||||
run: npm install --global "@vtsls/language-server@$VTSLS" "typescript@$TYPESCRIPT"
|
||||
|
||||
@@ -95,3 +107,10 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server
|
||||
- run: pnpm smoke smoke/run.test.ts -t "^${{ matrix.language }}$"
|
||||
- run: pnpm smoke smoke/debug.test.ts -t "^${{ matrix.language }}$"
|
||||
# The IDE installs the debugger from GitHub releases: authenticated, against rate limits.
|
||||
env: { GITHUB_TOKEN: "${{ github.token }}" }
|
||||
@@ -69,6 +69,11 @@ Shared agent skills live in `.agents/skills/` (`.claude/skills/` links to them):
|
||||
- `cargo nextest run --workspace` (or `cargo test`), `cargo clippy --workspace --all-targets -- -D warnings`, `cargo fmt --all`.
|
||||
- `cargo test -p ide-host --test language_smoke -- --ignored --exact <language>` — code intelligence
|
||||
on `fixtures/` with that language's real server installed (CI: `Language smoke`, pinned versions).
|
||||
- `pnpm smoke smoke/run.test.ts -t <language>` — run and test that language's fixture through the
|
||||
run store and a real `openidea-server` (build it first: `cargo build -p ide-host --bin openidea-server`).
|
||||
- `pnpm smoke smoke/debug.test.ts -t <language>` — debug that fixture's entry point the same way, with
|
||||
the debugger the IDE installs (on macOS, CodeLLDB and Delve may need Developer Mode:
|
||||
`sudo DevToolsSecurity -enable`).
|
||||
- `pnpm test`, `pnpm e2e`, `pnpm lint`, `pnpm format:check`, `pnpm knip`, `pnpm typecheck`,
|
||||
`pnpm gen:bindings`.
|
||||
- `pnpm perf` (UI interaction latency / frames / leaks), `pnpm perf:rust` (criterion
|
||||
|
||||
Generated
+3
@@ -4662,12 +4662,14 @@ dependencies = [
|
||||
name = "ide-vcs"
|
||||
version = "0.1.36"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"gix",
|
||||
"ide-core",
|
||||
"ignore",
|
||||
"libc",
|
||||
"redb",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -4735,6 +4737,7 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml_edit 0.25.17+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"zip",
|
||||
]
|
||||
|
||||
|
||||
@@ -52,6 +52,15 @@ Their original copyright headers and Apache-2.0 licensing are retained.
|
||||
|
||||
At render time `src/ui/icons.tsx` may recolour monochrome icons to `currentColor`.
|
||||
|
||||
## JetBrains IntelliJ Platform VCS log graph algorithms
|
||||
|
||||
`crates/ide-vcs/src/bek.rs` and `src/features/vcs/graph.ts` adapt the Bek ordering,
|
||||
graph layout and LinearBek merge linearization algorithms from
|
||||
[intellij-community](https://github.com/JetBrains/intellij-community/tree/1d70d5c54302d655758edbd2f808fb8dea8b1764/platform/vcs-log/graph/src/com/intellij/vcs/log/graph)
|
||||
(`impl/facade/sort/bek`, `impl/permanent/GraphLayoutBuilder.kt` and
|
||||
`linearBek/LinearBekGraphBuilder.java`). Modified from the originals.
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
|
||||
## JetBrains IntelliJ Platform diff algorithm
|
||||
|
||||
`src/features/vcs/comparison/**` is a TypeScript port of the text comparison of
|
||||
|
||||
@@ -36,6 +36,23 @@ impl Default for InspectionSettings {
|
||||
}
|
||||
}
|
||||
|
||||
impl InspectionSettings {
|
||||
/// Overrides of the profile in effect; `Default`'s when `profile` names none that exists.
|
||||
pub fn active(&self) -> &BTreeMap<String, InspectionOverride> {
|
||||
let custom = |level: &str, key: &str| match level {
|
||||
"ide" => self.ide_profiles.get(key),
|
||||
"project" => self.project_profiles.get(key),
|
||||
_ => None,
|
||||
};
|
||||
match self.profile.as_str() {
|
||||
"project" => &self.project,
|
||||
id => {
|
||||
id.split_once(':').and_then(|(level, key)| custom(level, key)).map_or(&self.default, |p| &p.overrides)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A named profile made with Copy.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
@@ -111,4 +128,20 @@ mod tests {
|
||||
let back: InspectionSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap();
|
||||
assert_eq!(back, s);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_profile_falls_back_to_default() {
|
||||
let off = |id: &str| {
|
||||
BTreeMap::from([(id.to_owned(), InspectionOverride { enabled: Some(false), ..Default::default() })])
|
||||
};
|
||||
let mut s = InspectionSettings { default: off("a"), project: off("b"), ..Default::default() };
|
||||
s.ide_profiles.insert("strict".into(), CustomInspectionProfile { name: "Strict".into(), overrides: off("c") });
|
||||
assert!(s.active().contains_key("a"));
|
||||
s.profile = "project".into();
|
||||
assert!(s.active().contains_key("b"));
|
||||
s.profile = "ide:strict".into();
|
||||
assert!(s.active().contains_key("c"));
|
||||
s.profile = "project:gone".into();
|
||||
assert!(s.active().contains_key("a"));
|
||||
}
|
||||
}
|
||||
@@ -716,6 +716,12 @@ pub struct PhpSettings {
|
||||
/// PHP version the language server checks the code against (`7.4`); empty = from
|
||||
/// `composer.json` (`config.platform.php`, else the lowest version `require.php` allows).
|
||||
pub language_level: String,
|
||||
/// Folders outside the project whose code the language server indexes too (a platform a
|
||||
/// module extends); absolute, `~/`, or relative to the project.
|
||||
pub include_paths: Vec<String>,
|
||||
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
|
||||
/// `composer.json` (`wordpress`).
|
||||
pub stubs: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -589,11 +589,12 @@ impl DebugManager {
|
||||
if let Some(command) = custom_command(&spec.command) {
|
||||
spec.command = command;
|
||||
}
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy).
|
||||
// debugpy: run the adapter with the debuggee's interpreter (venvs carry their own debugpy),
|
||||
// or with the launcher's when the debuggee runs through a manager (`uv run python`).
|
||||
if config.adapter == "debugpy" {
|
||||
let python = match arguments.get("python") {
|
||||
Some(Value::String(p)) => Some(p.clone()),
|
||||
Some(Value::Array(a)) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
let python = match (arguments.get("debugLauncherPython"), arguments.get("python")) {
|
||||
(Some(Value::String(p)), _) | (_, Some(Value::String(p))) => Some(p.clone()),
|
||||
(_, Some(Value::Array(a))) => a.first().and_then(Value::as_str).map(str::to_owned),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(python) = python {
|
||||
@@ -651,8 +652,19 @@ impl DebugManager {
|
||||
{
|
||||
arguments["project"] = json!(real);
|
||||
}
|
||||
if config.adapter == "codelldb" && arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
if config.adapter == "codelldb" {
|
||||
if arguments.get("sourceLanguages").is_none() {
|
||||
arguments["sourceLanguages"] = json!(["rust"]);
|
||||
}
|
||||
// The Debug Console and Evaluate Expression take expressions, as with every other
|
||||
// debugger; LLDB commands still run with a leading backtick.
|
||||
match arguments.get_mut("_adapterSettings") {
|
||||
None => arguments["_adapterSettings"] = json!({"consoleMode": "evaluate"}),
|
||||
Some(Value::Object(settings)) => {
|
||||
settings.entry("consoleMode").or_insert_with(|| json!("evaluate"));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
let port = debug_server.map(|s| s.port);
|
||||
self.start_session(id, owner, config.name, config.adapter, None, config.request, arguments, spec, port).await
|
||||
|
||||
@@ -698,6 +698,37 @@ async fn js_debug_launch_gets_port_and_workspace_folder() {
|
||||
assert_eq!(launch["type"], "pwa-node");
|
||||
}
|
||||
|
||||
/// CodeLLDB treats console input as LLDB commands unless told otherwise: Evaluate Expression
|
||||
/// and the Debug Console send expressions, so its console evaluates them. A launch
|
||||
/// configuration that picks a console mode keeps it.
|
||||
#[tokio::test]
|
||||
async fn codelldb_console_evaluates_expressions() {
|
||||
let launch_with = |extra: Value| async move {
|
||||
let h = harness(Script::default());
|
||||
let config = LaunchConfig {
|
||||
name: "demo".into(),
|
||||
adapter: "codelldb".into(),
|
||||
request: "launch".into(),
|
||||
program: Some("/p/target/debug/demo".into()),
|
||||
args: vec![],
|
||||
cwd: Some("/p".into()),
|
||||
env: BTreeMap::new(),
|
||||
extra_json: Some(extra.to_string()),
|
||||
};
|
||||
h.start(config).await.unwrap();
|
||||
h.request("launch")["arguments"].clone()
|
||||
};
|
||||
let launch = launch_with(json!({})).await;
|
||||
assert_eq!(launch["_adapterSettings"]["consoleMode"], "evaluate");
|
||||
assert_eq!(launch["sourceLanguages"], json!(["rust"]));
|
||||
|
||||
let launch =
|
||||
launch_with(json!({"_adapterSettings": {"consoleMode": "commands", "showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "commands", "showDisassembly": "never"}));
|
||||
let launch = launch_with(json!({"_adapterSettings": {"showDisassembly": "never"}})).await;
|
||||
assert_eq!(launch["_adapterSettings"], json!({"consoleMode": "evaluate", "showDisassembly": "never"}));
|
||||
}
|
||||
|
||||
/// An adapter that is already listening (java-debug inside jdtls): the client
|
||||
/// connects over TCP to the `debugServer` port instead of spawning anything.
|
||||
#[tokio::test]
|
||||
|
||||
@@ -79,6 +79,7 @@ pub struct Host {
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
pub(crate) external_logs: RwLock<HashMap<String, Vec<Arc<ide_vcs::Repo>>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
@@ -122,6 +123,7 @@ impl Host {
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
external_logs: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
@@ -178,6 +180,10 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
self.external_logs.write().remove(self.owner_window(window));
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
let project = self.projects.write().remove(window);
|
||||
if let Some(project) = project {
|
||||
if !project.light {
|
||||
@@ -193,9 +199,6 @@ impl Host {
|
||||
// asked for, its runs, its shells and its debug sessions — and the language
|
||||
// servers finish shutting down before the window is gone, as IDEA closes a
|
||||
// project's processes.
|
||||
for request in self.project_requests.write().remove(self.owner_window(window)).unwrap_or_default() {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
self.processes.kill_project(&project.root);
|
||||
self.terminals.kill_key(self.owner_window(window));
|
||||
self.shutdown_project_workers_blocking(
|
||||
@@ -311,14 +314,20 @@ impl Host {
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
let token = self.begin_project_read_request(window, id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
/// Read-only work remains cancellable/disposed with its window in restricted projects.
|
||||
pub fn begin_project_read_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
|
||||
@@ -18,7 +18,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
let vd = crate::ops::lsp::lsp_virtual_document(host, window, uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -137,16 +137,23 @@ pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) ->
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
let paths = ide_workspace::file_ops::deletion_paths(&path)?;
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
for p in paths {
|
||||
h.record_deletion(&p, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
ide_workspace::file_ops::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
host.blocking(move || ide_workspace::file_ops::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
pub async fn rename(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_workspace::file_ops::rename(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
|
||||
@@ -260,6 +260,9 @@ pub async fn lsp_implementations(
|
||||
/// Contents of a read-only language-server document, including JDT library
|
||||
/// classes and disk class files. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
if uri.starts_with("git-revision:") {
|
||||
return crate::ops::vcs::revision_virtual_document(host, window, &uri).await;
|
||||
}
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
@@ -438,12 +441,30 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A command after a code action's edit that no server provides: what other editors'
|
||||
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
|
||||
/// is the action; there is nothing to run.
|
||||
async fn is_client_follow_up(
|
||||
project: &crate::project::Project,
|
||||
doc: DocId,
|
||||
server: Option<ide_core::ServerId>,
|
||||
command: &Command,
|
||||
) -> bool {
|
||||
let provided = project.lsp.provides_command(doc, server, &command.command).await;
|
||||
if !provided {
|
||||
tracing::debug!("skipping `{}` after the code action's edit: no server provides it", command.command);
|
||||
}
|
||||
!provided
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
@@ -676,6 +697,7 @@ pub async fn lsp_execute_code_action(
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let edited = action.edit.is_some();
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
@@ -683,6 +705,9 @@ pub async fn lsp_execute_code_action(
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
match action.command {
|
||||
Some(command) if edited && is_client_follow_up(&project, doc, Some(action.server), &command).await => {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
Some(command) => project
|
||||
.lsp
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_index::{ExtensionCount, FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -32,6 +32,11 @@ pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
|
||||
Ok(host.project(window)?.index.status())
|
||||
}
|
||||
|
||||
/// The project's file extensions, most common first.
|
||||
pub fn index_file_extensions(host: &Host, window: &str) -> IdeResult<Vec<ExtensionCount>> {
|
||||
Ok(host.project(window)?.index.file_extensions())
|
||||
}
|
||||
|
||||
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
@@ -160,6 +165,7 @@ pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) ->
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("index_file_extensions", |c| async move { index_file_extensions(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_dirs", |c| async move { find_dirs(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
|
||||
@@ -17,6 +17,8 @@ pub struct ProjectInfo {
|
||||
pub settings: Settings,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace: Option<ide_workspace::workspace::Workspace>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub unity: Option<ide_workspace::unity::UnityProjectInfo>,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
@@ -33,6 +35,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(),
|
||||
unity: ide_workspace::unity::detect(&project.root),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +175,12 @@ pub fn set_settings(
|
||||
host.apply_proxy();
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
let code_style = key == "editor" || key == "editor.codeStyle" || key.starts_with("editor.codeStyle.");
|
||||
// So do inspections that are compiler options (`ide-sdk` `configure_java_compiler`).
|
||||
let code_style = key == "editor"
|
||||
|| key == "editor.codeStyle"
|
||||
|| key.starts_with("editor.codeStyle.")
|
||||
|| key == "inspections"
|
||||
|| key.starts_with("inspections.");
|
||||
let toolchain = ["languages", "tools", "languageServers", "projectStructure"]
|
||||
.iter()
|
||||
.any(|k| key == *k || key.starts_with(&format!("{k}.")));
|
||||
|
||||
@@ -142,6 +142,21 @@ pub async fn run_gradle_jvm_args_script(_host: &Host, _window: &str, jvm_args: V
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Writes one of the IDE's Gradle init scripts (`text`; the file is named `<name>-<hash>`) on
|
||||
/// the machine that runs the build, for `--init-script`, and returns its path.
|
||||
pub async fn run_gradle_init_script(_host: &Host, _window: &str, name: String, text: String) -> IdeResult<String> {
|
||||
if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
|
||||
return Err(IdeError::invalid(format!("invalid init script name {name:?}")));
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_run::gradle_jvm::write_script(&ide_run::gradle_jvm::default_dir(), &name, &text)
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.map_err(|e| IdeError::internal(format!("cannot write the Gradle init script: {e}")))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
@@ -308,6 +323,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_gradle_jvm_args_script", |c| async move {
|
||||
run_gradle_jvm_args_script(&c.host, &c.window, c.arg("jvmArgs")?).await
|
||||
});
|
||||
d.add("run_gradle_init_script", |c| async move {
|
||||
run_gradle_init_script(&c.host, &c.window, c.arg("name")?, c.arg("text")?).await
|
||||
});
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
|
||||
@@ -34,6 +34,42 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// The PHP language level of the project `path` belongs to (a workspace member, by path, as
|
||||
/// the language server configuration picks it).
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevels {
|
||||
/// The one Intelephense checks against; `None`: its default.
|
||||
pub effective: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The one Auto picks, from `composer.json`.
|
||||
pub auto: Option<ide_sdk::php::PhpLanguageLevel>,
|
||||
/// The workspace member `path` belongs to: its own settings choose the level, not the
|
||||
/// workspace's. `None`: the opened project.
|
||||
pub member: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) -> IdeResult<PhpLanguageLevels> {
|
||||
let project = host.project(window)?;
|
||||
let (project_root, project_settings) = (project.root.clone(), project.settings.effective());
|
||||
let config_dir = host.config_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let member = ide_workspace::workspace::Workspace::load(&project_root).ok().flatten().and_then(|w| {
|
||||
w.projects.into_iter().filter(|p| path.starts_with(p)).max_by_key(|p| p.components().count())
|
||||
});
|
||||
let (root, settings) = match &member {
|
||||
Some(member) => (member, ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective()),
|
||||
None => (&project_root, project_settings),
|
||||
};
|
||||
PhpLanguageLevels {
|
||||
effective: ide_sdk::php::language_level(root, &settings),
|
||||
auto: ide_sdk::php::composer_level(root),
|
||||
member: member.map(ide_core::IpcPath),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
@@ -144,6 +180,10 @@ pub async fn sdk_python_pip(
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add(
|
||||
"sdk_php_language_level",
|
||||
|c| async move { sdk_php_language_level(&c.host, &c.window, c.arg("path")?).await },
|
||||
);
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
|
||||
@@ -12,6 +12,76 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::apply_git_executable;
|
||||
|
||||
/// Read-only repository access also works in a standalone log window.
|
||||
struct LogAccess {
|
||||
project: Option<std::sync::Arc<Project>>,
|
||||
external: Option<std::sync::Arc<Repo>>,
|
||||
cache: PathBuf,
|
||||
}
|
||||
|
||||
/// Immutable Git objects exposed through the editor's ordinary read-only VFS.
|
||||
pub async fn revision_virtual_document(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
uri: &str,
|
||||
) -> IdeResult<ide_lsp::types::VirtualDocument> {
|
||||
#[derive(Deserialize)]
|
||||
struct Target {
|
||||
repo: PathBuf,
|
||||
revision: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
let encoded = uri
|
||||
.strip_prefix("git-revision:")
|
||||
.and_then(|s| s.split('/').next())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("invalid Git revision URI"))?;
|
||||
if encoded.len() > 32768 || encoded.len() % 2 != 0 || !encoded.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
return Err(ide_core::IdeError::invalid("invalid Git revision URI"));
|
||||
}
|
||||
let bytes = (0..encoded.len())
|
||||
.step_by(2)
|
||||
.map(|i| {
|
||||
u8::from_str_radix(&encoded[i..i + 2], 16)
|
||||
.map_err(|_| ide_core::IdeError::invalid("invalid Git revision URI"))
|
||||
})
|
||||
.collect::<IdeResult<Vec<_>>>()?;
|
||||
let target: Target = serde_json::from_slice(&bytes).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let path = target.repo.join(&target.path);
|
||||
let text = vcs_file_at(host, window, path, Some(target.revision.clone()))
|
||||
.await?
|
||||
.ok_or_else(|| ide_core::IdeError::not_found("file is absent at this revision"))?;
|
||||
Ok(ide_lsp::types::VirtualDocument {
|
||||
uri: uri.into(),
|
||||
language_id: host.languages.detect(&target.path, text.lines().next()),
|
||||
text,
|
||||
title: target.path.file_name().unwrap_or_default().to_string_lossy().into_owned(),
|
||||
description: Some(format!("{} · {}", target.repo.display(), target.revision)),
|
||||
decompiled: false,
|
||||
})
|
||||
}
|
||||
impl LogAccess {
|
||||
fn new(host: &Host, window: &str, at: Option<&Path>) -> Self {
|
||||
let project = host.project(window).ok();
|
||||
let external = host
|
||||
.external_logs
|
||||
.read()
|
||||
.get(host.owner_window(window))
|
||||
.and_then(|rs| rs.iter().find(|r| Some(r.root()) == at).cloned())
|
||||
.filter(|repo| project.as_ref().is_none_or(|p| p.repos().at_root(repo.root()).is_none()));
|
||||
Self { project, external, cache: host.local_data_dir.join("cache/git-log") }
|
||||
}
|
||||
fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if let Some(repo) = &self.external {
|
||||
repo.enable_log_index(&self.cache);
|
||||
return f(repo);
|
||||
}
|
||||
self.project
|
||||
.as_ref()
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?
|
||||
.with_log_repo_at(at, f)
|
||||
}
|
||||
}
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -182,8 +252,8 @@ pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
|
||||
}
|
||||
|
||||
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
@@ -197,9 +267,21 @@ pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResu
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
let project = host.project(window).ok();
|
||||
let external = host.external_logs.read().get(host.owner_window(window)).and_then(|rs| {
|
||||
rs.iter().filter(|r| path.starts_with(r.root())).max_by_key(|r| r.root().components().count()).cloned()
|
||||
});
|
||||
blocking(move || {
|
||||
project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?))
|
||||
if let Some(project) = project {
|
||||
return project.with_log_repo_of(&path, |r| {
|
||||
r.file_at(&path, rev.as_deref(), project.vcs_encoding(&path, rev.as_deref())?)
|
||||
});
|
||||
}
|
||||
external.ok_or_else(|| ide_core::IdeError::unavailable("external Git log is not open"))?.file_at(
|
||||
&path,
|
||||
rev.as_deref(),
|
||||
encoding_rs::UTF_8,
|
||||
)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -246,8 +328,124 @@ pub async fn vcs_block_history(
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let request = query.request;
|
||||
let cancel =
|
||||
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
r.log_cancellable(&query, &cancel)
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log_refs(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_refs())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log_users(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_users())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_open_external_log(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
|
||||
let path = dunce::canonicalize(path)?;
|
||||
let repo = Repo::discover(&path)?.ok_or_else(|| ide_core::IdeError::unavailable("folder is not a Git worktree"))?;
|
||||
let root = repo.root().to_path_buf();
|
||||
repo.set_execution_allowed(false);
|
||||
repo.enable_log_index(&host.local_data_dir.join("cache/git-log"));
|
||||
if let Ok(project) = host.project(window) {
|
||||
project.open_external_log(&root)?;
|
||||
}
|
||||
let mut logs = host.external_logs.write();
|
||||
let roots = logs.entry(host.owner_window(window).to_string()).or_default();
|
||||
if !roots.iter().any(|r| r.root() == root) {
|
||||
roots.push(std::sync::Arc::new(repo));
|
||||
}
|
||||
Ok(root.into())
|
||||
}
|
||||
|
||||
pub async fn vcs_log_details(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
) -> IdeResult<LogDetails> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.log_details(&revision))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log_details_batch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revisions: Vec<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<LogDetails>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
revisions.iter().map(|id| r.log_details_cancellable(id, &cancel)).collect()
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_log_index(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<LogIndexProgress> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
let cancel =
|
||||
request.map_or_else(ide_core::CancellationToken::new, |id| host.begin_project_read_request(window, id));
|
||||
let result = blocking(move || {
|
||||
project.with_log_repo_at(repo.as_deref(), |r| {
|
||||
r.enable_log_index(&project.cache);
|
||||
if request.is_some() { r.index_log(&cancel) } else { Ok(r.log_index_progress()) }
|
||||
})
|
||||
})
|
||||
.await;
|
||||
if let Some(request) = request {
|
||||
host.end_request(request);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_revision_children(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
directory: String,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_children(&revision, &directory))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_revision_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
revision: String,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.revision_files(&revision))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit_files(
|
||||
@@ -256,8 +454,8 @@ pub async fn vcs_commit_files(
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
@@ -267,13 +465,13 @@ pub async fn vcs_commits_changes(
|
||||
repo: Option<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
@@ -628,8 +826,8 @@ pub async fn vcs_config_get(
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
}
|
||||
|
||||
/// Local history operation that may take a while (hooks, large checkouts); git's
|
||||
@@ -690,8 +888,8 @@ pub async fn vcs_create_tag(
|
||||
}
|
||||
|
||||
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
}
|
||||
|
||||
/// Merge dialog: merges `branches` into the current branch.
|
||||
@@ -727,8 +925,8 @@ pub async fn vcs_rebase_with(
|
||||
}
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
@@ -815,8 +1013,8 @@ pub async fn vcs_diff_with_worktree(
|
||||
repo: Option<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
@@ -827,8 +1025,8 @@ pub async fn vcs_diff_revisions(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
let project = LogAccess::new(host, window, repo.as_deref());
|
||||
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
@@ -1015,6 +1213,25 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
vcs_block_history(&c.host, &c.window, request, path, text, lines).await
|
||||
});
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_log_refs", |c| async move { vcs_log_refs(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_log_users", |c| async move { vcs_log_users(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_open_external_log", |c| async move { vcs_open_external_log(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_log_details", |c| async move {
|
||||
vcs_log_details(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
|
||||
});
|
||||
d.add("vcs_log_details_batch", |c| async move {
|
||||
vcs_log_details_batch(&c.host, &c.window, c.arg("repo")?, c.arg("revisions")?, c.arg("request")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_log_index",
|
||||
|c| async move { vcs_log_index(&c.host, &c.window, c.arg("repo")?, c.arg("request")?).await },
|
||||
);
|
||||
d.add("vcs_revision_children", |c| async move {
|
||||
vcs_revision_children(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?, c.arg("directory")?).await
|
||||
});
|
||||
d.add("vcs_revision_files", |c| async move {
|
||||
vcs_revision_files(&c.host, &c.window, c.arg("repo")?, c.arg("revision")?).await
|
||||
});
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
});
|
||||
|
||||
@@ -667,6 +667,17 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError> {
|
||||
let event = PluginEvent::OpenLocation {
|
||||
plugin_id: self.id().to_owned(),
|
||||
path: path.to_path_buf().into(),
|
||||
line,
|
||||
column,
|
||||
};
|
||||
self.shared.sink.emit(client, event);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
|
||||
@@ -114,6 +114,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::ViewChanged { .. }
|
||||
| PluginEvent::ViewMessage { .. }
|
||||
| PluginEvent::OpenTerminal { .. }
|
||||
| PluginEvent::OpenLocation { .. }
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -192,6 +192,8 @@ pub enum PluginEvent {
|
||||
args: Vec<String>,
|
||||
cwd: ide_core::IpcPath,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
OpenLocation { plugin_id: String, path: ide_core::IpcPath, line: u32, column: u32 },
|
||||
/// Plugin languages, or the servers they may start, changed: re-fetch
|
||||
/// `languages` (the host re-detects open documents: [`crate::Host::plugin_languages_changed`]).
|
||||
LanguagesChanged,
|
||||
|
||||
@@ -378,9 +378,19 @@ pub(super) fn view(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
}
|
||||
let mut out = UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?);
|
||||
out.files = files;
|
||||
if let Some(icon) = d.icon.as_deref().filter(|i| i.ends_with(".svg")) {
|
||||
let path = std::path::PathBuf::from(icon);
|
||||
let svg = super::icons::read_icon(pkg, &path)?;
|
||||
out.assets.push((view_icon_asset(&d.id), svg));
|
||||
out.files.push(path);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn view_icon_asset(id: &str) -> String {
|
||||
format!("viewIcon/{id}")
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CustomEditorDecl {
|
||||
|
||||
@@ -34,6 +34,8 @@ pub struct Project {
|
||||
pub lsp: Arc<LspManager>,
|
||||
/// Git repositories: the one containing the project root and those nested below it.
|
||||
pub repos: Arc<RwLock<Repos>>,
|
||||
/// Explicitly opened external logs remain read-only and outside project indexing.
|
||||
pub(crate) external_log_repos: RwLock<Vec<Arc<Repo>>>,
|
||||
/// Incrementally maintained syntax trees of the open documents.
|
||||
pub syntax: Arc<SyntaxCache>,
|
||||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||||
@@ -207,6 +209,7 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
@@ -462,6 +465,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos,
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
@@ -595,6 +599,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos: Arc::default(),
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots: Arc::default(),
|
||||
@@ -692,7 +697,14 @@ fn scan_repositories(
|
||||
build_cache: &Path,
|
||||
) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
let skip = |p: &Path| {
|
||||
p.file_name().is_some_and(|n| {
|
||||
let name = n.to_string_lossy();
|
||||
ignored.is_ignored_name(&name)
|
||||
|| (ide_workspace::unity::GENERATED_DIRS.contains(&name.as_ref())
|
||||
&& p.parent().is_some_and(ide_workspace::unity::is_project))
|
||||
})
|
||||
};
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(content_roots(&settings.effective(), root, build_cache));
|
||||
match Repos::discover_roots(&roots, &skip) {
|
||||
@@ -739,6 +751,51 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_external_log(&self, path: &Path) -> IdeResult<PathBuf> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let repo = Repo::discover(&root)?.ok_or_else(|| IdeError::unavailable("folder is not a Git worktree"))?;
|
||||
let root = repo.root().to_path_buf();
|
||||
repo.set_execution_allowed(false);
|
||||
let mut external = self.external_log_repos.write();
|
||||
if !external.iter().any(|r| r.root() == root) {
|
||||
external.push(Arc::new(repo));
|
||||
}
|
||||
Ok(root)
|
||||
}
|
||||
|
||||
/// Log read operations may also use an explicitly opened external worktree.
|
||||
/// Mutations continue to use `with_repo_at` and cannot reach these repositories.
|
||||
pub fn with_log_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if let Some(path) = at
|
||||
&& self.repos().at_root(path).is_none()
|
||||
{
|
||||
let repo = self
|
||||
.external_log_repos
|
||||
.read()
|
||||
.iter()
|
||||
.find(|r| r.root() == path)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::unavailable("external Git log is not open"))?;
|
||||
return f(&repo);
|
||||
}
|
||||
self.with_repo_at(at, f)
|
||||
}
|
||||
|
||||
pub fn with_log_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
if self.repos().for_path(path).is_some() {
|
||||
return self.with_repo_of(path, f);
|
||||
}
|
||||
let repo = self
|
||||
.external_log_repos
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|r| path.starts_with(r.root()))
|
||||
.max_by_key(|r| r.root().components().count())
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::unavailable("path is not in an open Git log"))?;
|
||||
f(&repo)
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
match self.repos().for_path(path) {
|
||||
@@ -955,7 +1012,7 @@ impl Project {
|
||||
return encoding_rs::Encoding::for_label(loaded.encoding.as_bytes())
|
||||
.ok_or_else(|| ide_core::IdeError::invalid("unknown file encoding"));
|
||||
}
|
||||
let bytes = self.with_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
let bytes = self.with_log_repo_of(path, |r| r.file_bytes_at(path, rev))?.unwrap_or_default();
|
||||
Ok(ide_vfs::detect_encoding(&bytes, fallback))
|
||||
}
|
||||
|
||||
|
||||
@@ -188,7 +188,7 @@ impl Dispatcher {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_rename", |c| async move { ops::fs::rename(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "index_file_extensions"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "deploy_test_connection"
|
||||
@@ -307,6 +308,14 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "vcs_blame"
|
||||
| "vcs_block_history"
|
||||
| "vcs_log"
|
||||
| "vcs_log_refs"
|
||||
| "vcs_log_users"
|
||||
| "vcs_log_details"
|
||||
| "vcs_log_details_batch"
|
||||
| "vcs_log_index"
|
||||
| "vcs_revision_children"
|
||||
| "vcs_revision_files"
|
||||
| "vcs_open_external_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
//! Explicit external logs allow history reads without adding a writable project root.
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops::vcs};
|
||||
use std::{path::Path, process::Command, sync::Arc};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let output = Command::new("git")
|
||||
.current_dir(root)
|
||||
.args(["-c", "user.name=Test", "-c", "[email protected]", "-c", "commit.gpgsign=false"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
String::from_utf8(output.stdout).unwrap().trim().to_owned()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn external_history_is_readable_and_never_becomes_a_write_target() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
let external = base.join("external");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&external).unwrap();
|
||||
git(&external, &["init", "-b", "main"]);
|
||||
let deleted = external.join("deleted.txt");
|
||||
std::fs::write(&deleted, "historical content\n").unwrap();
|
||||
git(&external, &["add", "."]);
|
||||
git(&external, &["commit", "-m", "initial"]);
|
||||
let first = git(&external, &["rev-parse", "HEAD"]);
|
||||
git(&external, &["rm", "deleted.txt"]);
|
||||
git(&external, &["commit", "-m", "delete"]);
|
||||
let head = git(&external, &["rev-parse", "HEAD"]);
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
// A standalone editor needs no project, indexer, or writable VCS root.
|
||||
vcs::vcs_open_external_log(&host, "standalone", external.clone()).await.unwrap();
|
||||
let standalone = vcs::vcs_log(&host, "standalone", Some(external.clone()), Default::default()).await.unwrap();
|
||||
assert_eq!(standalone[0].id, head);
|
||||
let details = vcs::vcs_log_details_batch(
|
||||
&host,
|
||||
"standalone",
|
||||
Some(external.clone()),
|
||||
vec![head.clone(), first.clone()],
|
||||
ide_core::RequestId(987),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(details.iter().all(|d| d.signature == "N"));
|
||||
assert_eq!(
|
||||
vcs::vcs_revision_children(&host, "standalone", Some(external.clone()), first.clone(), String::new())
|
||||
.await
|
||||
.unwrap(),
|
||||
vec!["deleted.txt"]
|
||||
);
|
||||
assert!(host.project("standalone").is_err());
|
||||
host.trust.decide(&external, false, false).unwrap();
|
||||
let restricted = host.open_project("restricted", &external).unwrap();
|
||||
assert!(!restricted.is_trusted());
|
||||
vcs::vcs_open_external_log(&host, "restricted", external.clone()).await.unwrap();
|
||||
let restricted_log = vcs::vcs_log(
|
||||
&host,
|
||||
"restricted",
|
||||
Some(external.clone()),
|
||||
ide_vcs::LogQuery { request: Some(ide_core::RequestId(988)), summary_only: true, ..Default::default() },
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restricted_log[0].id, head);
|
||||
assert_eq!(
|
||||
vcs::vcs_log_details_batch(
|
||||
&host,
|
||||
"restricted",
|
||||
Some(external.clone()),
|
||||
vec![head.clone()],
|
||||
ide_core::RequestId(989)
|
||||
)
|
||||
.await
|
||||
.unwrap()[0]
|
||||
.signature,
|
||||
"N"
|
||||
);
|
||||
host.close_project("restricted");
|
||||
let payload =
|
||||
serde_json::to_vec(&serde_json::json!({ "repo": external, "revision": first, "path": "deleted.txt" })).unwrap();
|
||||
let hex = payload.iter().map(|b| format!("{b:02x}")).collect::<String>();
|
||||
let document =
|
||||
vcs::revision_virtual_document(&host, "standalone", &format!("git-revision:{hex}/deleted.txt")).await.unwrap();
|
||||
assert_eq!(document.text, "historical content\n");
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.is_err());
|
||||
vcs::vcs_open_external_log(&host, "main", external.clone()).await.unwrap();
|
||||
let log = vcs::vcs_log(&host, "main", Some(external.clone()), Default::default()).await.unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![head.as_str(), first.as_str()]);
|
||||
assert_eq!(
|
||||
vcs::vcs_file_at(&host, "main", deleted.clone(), Some(first.clone())).await.unwrap().as_deref(),
|
||||
Some("historical content\n")
|
||||
);
|
||||
assert!(project.repos().at_root(&external).is_none());
|
||||
assert!(vcs::vcs_checkout(&host, "main", Some(external.clone()), first.clone(), false, None).await.is_err());
|
||||
assert!(vcs::vcs_apply_patch(&host, "main", Some(external.clone()), String::new()).await.is_err());
|
||||
assert_eq!(git(&external, &["rev-parse", "HEAD"]), head);
|
||||
assert!(!deleted.exists());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Language smoke tests: the everyday code intelligence of each language — completion, go to
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename and
|
||||
//! formatting — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! definition, Find Usages, hover, diagnostics with a quick fix, an intention, rename,
|
||||
//! formatting, parameter info, the Structure view, auto-import, Optimize Imports and moving a
|
||||
//! file — on its `fixtures/` project, through the `ops` the app's commands call and the
|
||||
//! language server the app starts by default.
|
||||
//!
|
||||
//! `#[ignore]`d because they need the servers installed. CI's `Language smoke` job installs
|
||||
@@ -68,6 +69,35 @@ struct Checks {
|
||||
indent: Indent,
|
||||
/// Renaming the symbol to the new name edits these files.
|
||||
rename: (At, &'static str, &'static [&'static str]),
|
||||
/// Parameter info in a call being typed: a signature naming this parameter.
|
||||
signature: (Typed, &'static str),
|
||||
/// The Structure view of a file: these symbols, at any depth.
|
||||
outline: (&'static str, &'static [&'static str]),
|
||||
/// Completing a name the file doesn't import yet also imports it.
|
||||
auto_import: AutoImport,
|
||||
/// Optimize Imports, with an import typed after a line.
|
||||
organize_imports: Option<(Typed, Imports)>,
|
||||
/// Moving a file updates its references: (file, new path, a file that then contains this).
|
||||
file_move: Option<(&'static str, &'static str, &'static str, &'static str)>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct AutoImport {
|
||||
/// The line typed, ending in the start of the name.
|
||||
typed: Typed,
|
||||
/// The completion item.
|
||||
label: &'static str,
|
||||
/// What the item's import adds to the file.
|
||||
result: &'static str,
|
||||
}
|
||||
|
||||
/// What Optimize Imports does with the typed import.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Imports {
|
||||
/// Removes it (unused).
|
||||
Removed,
|
||||
/// Sorts it before this line.
|
||||
Before(&'static str),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
@@ -432,6 +462,146 @@ impl Smoke {
|
||||
}
|
||||
}
|
||||
|
||||
async fn signature(&self, call: Typed, parameter: &str) {
|
||||
let (doc, pos, before) = self.type_line(call).await;
|
||||
self.retry("parameter info", self.warm_up, || async {
|
||||
let found = ops::lsp::lsp_signature_help(&self.host, WINDOW, doc, pos).await;
|
||||
match &found {
|
||||
Ok(Some(h)) if h.signatures.iter().any(|s| s.label.contains(parameter)) => Ok(()),
|
||||
_ => Err(found.map(|h| h.map(|h| h.signatures.into_iter().map(|s| s.label).collect::<Vec<_>>()))),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn outline(&self, file: &str, symbols: &[&str]) {
|
||||
let (doc, _) = self.doc(file).await;
|
||||
fn names(items: &[ide_core::OutlineItem], out: &mut BTreeSet<String>) {
|
||||
for item in items {
|
||||
out.insert(item.name.clone());
|
||||
names(&item.children, out);
|
||||
}
|
||||
}
|
||||
self.retry("structure", self.warm_up, || async {
|
||||
let found = ops::doc::doc_outline(&self.host, WINDOW, doc).await;
|
||||
let mut shown = BTreeSet::new();
|
||||
if let Ok(items) = &found {
|
||||
names(items, &mut shown);
|
||||
}
|
||||
if symbols.iter().all(|s| shown.contains(*s)) { Ok(()) } else { Err(shown) }
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn auto_import(&self, a: AutoImport) {
|
||||
let (doc, pos, before) = self.type_line(a.typed).await;
|
||||
assert!(!before.contains(a.result), "{a:?}: the file already contains the result");
|
||||
self.retry("auto-import completion", self.warm_up, || async {
|
||||
let list = ops::lsp::lsp_completion(&self.host, WINDOW, RequestId(6), doc, pos, None).await;
|
||||
let items = match &list {
|
||||
Ok(l) => l.items.iter().filter(|i| i.label == a.label).cloned().collect::<Vec<_>>(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
let mut imports = Vec::new();
|
||||
for item in items {
|
||||
// Servers compute the import when the item is resolved (on selection).
|
||||
let item = if item.additional_edits.is_empty() {
|
||||
ops::lsp::lsp_resolve_completion(&self.host, WINDOW, doc, item.clone()).await.unwrap_or(item)
|
||||
} else {
|
||||
item
|
||||
};
|
||||
let mut document = ide_text::Document::new(&text);
|
||||
if document.apply_edits(&item.additional_edits).is_ok() && document.text().contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
// Or accepting it runs a server command that edits the file (kotlin-lsp).
|
||||
if let Some(command) = item.command.clone() {
|
||||
let ran = ops::lsp::lsp_execute_command(&self.host, WINDOW, doc, command).await;
|
||||
let text = self.doc(a.typed.file).await.1;
|
||||
if ran.is_ok() && text.contains(a.result) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
imports.push(format!("{:?} {:?}", item.additional_edits, item.command));
|
||||
}
|
||||
let labels: Vec<String> = list.map(|l| l.items.into_iter().take(20).map(|i| i.label).collect()).unwrap();
|
||||
Err(format!("{:?} items' imports: {imports:?}; items: {labels:?}", a.label))
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn organize_imports(&self, import: Typed, expect: Imports) {
|
||||
let (doc, _, before) = self.type_line(import).await;
|
||||
let typed = import.text.trim();
|
||||
let organized = |text: &str| match expect {
|
||||
Imports::Removed => !text.lines().any(|l| l.trim() == typed),
|
||||
Imports::Before(line) => {
|
||||
let at = |needle: &str| text.lines().position(|l| l.trim() == needle);
|
||||
matches!((at(typed), at(line)), (Some(a), Some(b)) if a < b)
|
||||
}
|
||||
};
|
||||
self.retry("optimize imports", self.warm_up, || async {
|
||||
let text = self.doc(import.file).await.1;
|
||||
// The whole file, as Optimize Imports asks (positions past the end fail on jdtls).
|
||||
let last = text.split('\n').count() as u32 - 1;
|
||||
let whole = Range::new(Pos::new(0, 0), Pos::new(last, utf16_len(text.rsplit('\n').next().unwrap_or(""))));
|
||||
let kind = "source.organizeImports".to_string();
|
||||
let found = ops::lsp::lsp_code_actions(&self.host, WINDOW, doc, whole, vec![kind.clone()]).await;
|
||||
let actions: Vec<_> = match &found {
|
||||
Ok(a) => a.iter().filter(|a| a.kind.as_ref().is_some_and(|k| k.starts_with(&kind))).cloned().collect(),
|
||||
Err(e) => return Err(format!("{e}")),
|
||||
};
|
||||
let Some(action) = actions.iter().find(|a| a.is_preferred).or(actions.first()).cloned() else {
|
||||
return Err(format!(
|
||||
"no {kind} action: {:?}",
|
||||
found.map(|a| a.into_iter().map(|a| a.title).collect::<Vec<_>>())
|
||||
));
|
||||
};
|
||||
ops::lsp::lsp_apply_code_action(&self.host, WINDOW, doc, action).await.map_err(|e| e.to_string())?;
|
||||
let text = self.doc(import.file).await.1;
|
||||
if organized(&text) { Ok(()) } else { Err(text) }
|
||||
})
|
||||
.await;
|
||||
self.restore(doc, &before).await;
|
||||
}
|
||||
|
||||
async fn file_move(&self, from: &str, to: &str, referencing: &str, result: &str) {
|
||||
// The servers know the referencing file (opened, as in an editor).
|
||||
let _ = self.doc(referencing).await;
|
||||
let moves = vec![ide_workspace::FileMove { from: self.path(from).into(), to: self.path(to).into() }];
|
||||
let edit = self
|
||||
.retry("move file", self.warm_up, || async {
|
||||
let found =
|
||||
ops::refactor::refactor_file_move_edits(&self.host, WINDOW, RequestId(7), moves.clone()).await;
|
||||
let want = self.path(referencing);
|
||||
match &found {
|
||||
Ok(e)
|
||||
if e.edit
|
||||
.files
|
||||
.iter()
|
||||
.any(|f| matches!(f, ide_core::FileEdit::Edit { path, .. } if **path == *want)) =>
|
||||
{
|
||||
Ok(e.edit.clone())
|
||||
}
|
||||
_ => Err(found),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
ops::refactor::refactor_move_files(&self.host, WINDOW, moves, edit, "Move".into())
|
||||
.await
|
||||
.unwrap_or_else(|e| self.fail("move file", e));
|
||||
if !self.path(to).is_file() {
|
||||
self.fail("move file", format_args!("{to} doesn't exist after the move"));
|
||||
}
|
||||
let text = self.doc(referencing).await.1;
|
||||
if !text.contains(result) {
|
||||
self.fail("move file", format_args!("{referencing} has no {result:?} after the move:\n{text}"));
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(&self, c: &Checks) {
|
||||
self.definition(c.definition.0, c.definition.1).await;
|
||||
self.references(c.references.0, c.references.1).await;
|
||||
@@ -440,7 +610,17 @@ impl Smoke {
|
||||
self.quick_fix(c.quick_fix).await;
|
||||
self.intention(c.intention.0, c.intention.1).await;
|
||||
self.format(c.format.0, c.indent, c.format.1).await;
|
||||
self.signature(c.signature.0, c.signature.1).await;
|
||||
self.outline(c.outline.0, c.outline.1).await;
|
||||
self.auto_import(c.auto_import).await;
|
||||
if let Some((import, expect)) = c.organize_imports {
|
||||
self.organize_imports(import, expect).await;
|
||||
}
|
||||
self.rename(c.rename.0, c.rename.1, c.rename.2).await;
|
||||
// Last: it moves a file the other checks use.
|
||||
if let Some((from, to, referencing, result)) = c.file_move {
|
||||
self.file_move(from, to, referencing, result).await;
|
||||
}
|
||||
let shutdown = self.host.project(WINDOW).unwrap().lsp.clone();
|
||||
let _ = tokio::time::timeout(Duration::from_secs(20), shutdown.shutdown_all()).await;
|
||||
}
|
||||
@@ -480,6 +660,22 @@ const TYPESCRIPT: Checks = Checks {
|
||||
"formatPrice",
|
||||
&["src/index.ts", "src/money.ts"],
|
||||
),
|
||||
signature: (typed("src/index.ts", "const total = cart.total();", " cart.add("), "product"),
|
||||
outline: ("src/cart.ts", &["Cart", "add", "total"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/index.ts", "const total = cart.total();", " addMon"),
|
||||
label: "addMoney",
|
||||
result: "addMoney",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed(
|
||||
"src/index.ts",
|
||||
"import { cents, formatMoney } from \"./money.ts\";",
|
||||
"import { readFileSync } from \"node:fs\";",
|
||||
),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some(("src/money.ts", "src/lib/money.ts", "src/index.ts", "\"./lib/money.ts\"")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -521,6 +717,19 @@ const PYTHON: Checks = Checks {
|
||||
"TaskBoard",
|
||||
&["src/tasks/cli.py", "src/tasks/models.py"],
|
||||
),
|
||||
signature: (typed("src/tasks/cli.py", "tasks = build_demo()", " tasks.add("), "title"),
|
||||
outline: ("src/tasks/models.py", &["Priority", "Task", "TaskList", "pending"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/tasks/cli.py", "tasks = build_demo()", " stamp = datetim"),
|
||||
label: "datetime",
|
||||
result: "import datetime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/tasks/models.py", "from enum import IntEnum", "import abc"),
|
||||
Imports::Before("import os"),
|
||||
)),
|
||||
// basedpyright has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -558,6 +767,16 @@ const RUST: Checks = Checks {
|
||||
),
|
||||
indent: Indent::Spaces(4),
|
||||
rename: (at("src/parser.rs", "pub fn parse(text: &str)", "parse"), "parse_all", &["src/main.rs", "src/parser.rs"]),
|
||||
signature: (typed("src/main.rs", "let mut inventory = Inventory::new();", " inventory.take("), "sku"),
|
||||
outline: ("src/lib.rs", &["Inventory", "Item", "take"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("src/main.rs", "let mut inventory = Inventory::new();", " let _seen = HashSe"),
|
||||
label: "HashSet",
|
||||
result: "use std::collections::HashSet;",
|
||||
},
|
||||
// rust-analyzer has no `source.organizeImports`.
|
||||
organize_imports: None,
|
||||
file_move: Some(("src/parser.rs", "src/csv.rs", "src/lib.rs", "pub mod csv;")),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -597,6 +816,16 @@ const GO: Checks = Checks {
|
||||
"Open",
|
||||
&["cmd/server/main.go", "internal/store/store.go"],
|
||||
),
|
||||
signature: (typed("cmd/server/main.go", "s := store.New()", "\ts.Create("), "title"),
|
||||
outline: ("internal/store/store.go", &["Store", "New", "(*Store).Create"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed("cmd/server/main.go", "s := store.New()", "\t_ = strings.ToUpp"),
|
||||
label: "ToUpper",
|
||||
result: "\t\"strings\"",
|
||||
},
|
||||
organize_imports: Some((typed("cmd/server/main.go", "\"strconv\"", "\t\"sort\""), Imports::Removed)),
|
||||
// gopls renames packages, not files: moving a Go file changes no references.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -653,6 +882,37 @@ const JAVA: Checks = Checks {
|
||||
"submit",
|
||||
&["src/main/java/com/example/orders/App.java", "src/main/java/com/example/orders/OrderBook.java"],
|
||||
),
|
||||
signature: (
|
||||
typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" book.place(",
|
||||
),
|
||||
"customer",
|
||||
),
|
||||
outline: (
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
&["OrderBook", "place(String, String, int, long)", "revenueCents()"],
|
||||
),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"long revenue = book.revenueCents();",
|
||||
" ArrayLis",
|
||||
),
|
||||
label: "ArrayList",
|
||||
result: "import java.util.ArrayList;",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/java/com/example/orders/OrderBook.java", "import java.util.Optional;", "import java.util.Map;"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
file_move: Some((
|
||||
"src/main/java/com/example/orders/OrderBook.java",
|
||||
"src/main/java/com/example/orders/Ledger.java",
|
||||
"src/main/java/com/example/orders/App.java",
|
||||
"Ledger book",
|
||||
)),
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
@@ -705,6 +965,26 @@ const KOTLIN: Checks = Checks {
|
||||
"find",
|
||||
&["src/main/kotlin/com/example/library/App.kt", "src/main/kotlin/com/example/library/Library.kt"],
|
||||
),
|
||||
signature: (
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "val library = Library()", " library.lend("),
|
||||
"isbn",
|
||||
),
|
||||
outline: ("src/main/kotlin/com/example/library/Library.kt", &["Library", "lend", "search"]),
|
||||
auto_import: AutoImport {
|
||||
typed: typed(
|
||||
"src/main/kotlin/com/example/library/App.kt",
|
||||
"val library = Library()",
|
||||
" val stamp = LocalDateTi",
|
||||
),
|
||||
label: "LocalDateTime",
|
||||
result: "import java.time.LocalDateTime",
|
||||
},
|
||||
organize_imports: Some((
|
||||
typed("src/main/kotlin/com/example/library/App.kt", "import java.time.LocalDate", "import java.util.UUID"),
|
||||
Imports::Removed,
|
||||
)),
|
||||
// kotlin-lsp has no `workspace/willRenameFiles`.
|
||||
file_move: None,
|
||||
};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
|
||||
@@ -2045,6 +2045,83 @@ fn pinned_programs_fill_views_and_open_terminals() {
|
||||
assert_eq!(args, ["follow", "build", "--lines=5"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_open_project_files_at_a_line() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("jobs"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
std::fs::write(f.project.join("jobs.log"), "build\ndeploy\nfailed\n").unwrap();
|
||||
let press = |button: &str| {
|
||||
f.take_events();
|
||||
let result = f.host.view_event("jobs", "jobs", ide_plugin::ViewEvent::Button { id: button.into() }, f.inv());
|
||||
let opened = f.take_events().into_iter().find_map(|(client, e)| match e {
|
||||
PluginEvent::OpenLocation { path, line, column, .. } => Some((client, path.0, line, column)),
|
||||
_ => None,
|
||||
});
|
||||
(result, opened)
|
||||
};
|
||||
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.is_ok(), "{result:?}");
|
||||
let (client, path, line, column) = opened.expect("a location");
|
||||
assert_eq!((client.as_deref(), line, column), (Some("main"), 2, 4));
|
||||
assert_eq!(path.canonicalize().unwrap(), f.project.join("jobs.log").canonicalize().unwrap());
|
||||
|
||||
let (result, opened) = press("outside");
|
||||
assert!(result.is_err(), "a path outside the project is refused");
|
||||
assert!(opened.is_none());
|
||||
|
||||
f.host.set_capability("jobs", "project.read", false).unwrap();
|
||||
let (result, opened) = press("log");
|
||||
assert!(result.unwrap_err().message.contains("project.read"), "needs project.read");
|
||||
assert!(opened.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn views_can_use_an_svg_icon_from_the_package() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let clean = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" fill="#08f"/></svg>"##;
|
||||
let package = |dir: &str, icon: &str, svg: &str| {
|
||||
let pkg = tmp.path().join(dir);
|
||||
for (path, text) in [("panel/index.html", "<p>panel</p>"), ("icons/panel.svg", svg)] {
|
||||
std::fs::create_dir_all(pkg.join(path).parent().unwrap()).unwrap();
|
||||
std::fs::write(pkg.join(path), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
r#"id = "panel-icon"
|
||||
name = "Panel Icon"
|
||||
version = "1.0.0"
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
[[contributes.views]]
|
||||
id = "panel"
|
||||
title = "Panel"
|
||||
icon = "{icon}"
|
||||
location = "bottom"
|
||||
web = "panel/index.html"
|
||||
"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
assert!(f.host.install_from_dir(&package("missing", "icons/missing.svg", clean), &Consent::all()).is_err());
|
||||
let scripted = clean.replace("<rect", "<script>alert(1)</script><rect");
|
||||
assert!(f.host.install_from_dir(&package("scripted", "icons/panel.svg", &scripted), &Consent::all()).is_err());
|
||||
|
||||
let info = f.install(&package("clean", "icons/panel.svg", clean));
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let view = f.host.ui_items("views").into_iter().find(|v| v.key == "panel").expect("the view");
|
||||
assert_eq!(view.decl["icon"].as_str(), Some("icons/panel.svg"));
|
||||
let assets = f.host.assets("panel-icon", "viewIcon/panel").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
|
||||
@@ -141,3 +141,19 @@ async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn gradle_init_scripts_are_written_by_the_host_that_runs_the_build() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let text = "allprojects { }\n".to_string();
|
||||
let path =
|
||||
ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text.clone()).await.unwrap();
|
||||
assert!(path.contains("openidea-gradle-tests-"), "{path}");
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), text);
|
||||
let again = ops::run::run_gradle_init_script(&host, "main", "openidea-gradle-tests".into(), text).await.unwrap();
|
||||
assert_eq!(again, path);
|
||||
// A name is not a path.
|
||||
let escaped = ops::run::run_gradle_init_script(&host, "main", "../escape".into(), String::new()).await;
|
||||
assert!(escaped.is_err(), "{escaped:?}");
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
use ide_core::{NullSink, RequestId};
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::{sync::Arc, time::Duration};
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_index() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("Game");
|
||||
for name in ["Assets/Scripts", "Packages", "ProjectSettings", "Library/PackageCache", "Temp", "Logs"] {
|
||||
std::fs::create_dir_all(root.join(name)).unwrap();
|
||||
}
|
||||
std::fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 6000.0.62f1\n").unwrap();
|
||||
std::fs::write(root.join("Assets/Scripts/Player.cs"), "class Player {}\n").unwrap();
|
||||
std::fs::write(root.join("Packages/manifest.json"), "{}\n").unwrap();
|
||||
std::fs::write(root.join("Library/PackageCache/Cached.cs"), "class Cached {}\n").unwrap();
|
||||
std::fs::write(root.join("Temp/Temporary.cs"), "class Temporary {}\n").unwrap();
|
||||
std::fs::write(root.join("Logs/Editor.log"), "log\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
let info = ops::project::info(&project);
|
||||
assert_eq!(info.name, "Game");
|
||||
let unity = info.unity.unwrap();
|
||||
assert_eq!(unity.editor_version.as_deref(), Some("6000.0.62f1"));
|
||||
assert!(unity.projects.is_empty() && unity.solutions.is_empty());
|
||||
assert!(project.index.wait_idle(Duration::from_secs(20)));
|
||||
assert_eq!(project.index.find_files("Player.cs", 100).len(), 1);
|
||||
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
|
||||
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
|
||||
}
|
||||
let hits = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let out = hits.clone();
|
||||
let query = serde_json::from_value(serde_json::json!({
|
||||
"pattern": "class", "isRegex": false, "caseSensitive": false, "wholeWord": false,
|
||||
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 100,
|
||||
}))
|
||||
.unwrap();
|
||||
ops::search::start(
|
||||
&host,
|
||||
"main",
|
||||
RequestId::next(),
|
||||
query,
|
||||
Arc::new(move |event| {
|
||||
if let ops::search::SearchEvent::File { file } = event {
|
||||
out.lock().unwrap().push(file.rel_path);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(*hits.lock().unwrap(), ["Assets/Scripts/Player.cs"]);
|
||||
let listing = ops::fs::read_dir(&host, "main", root.clone()).await.unwrap();
|
||||
assert!(listing.iter().any(|e| e.name == "Library" && e.excluded));
|
||||
assert!(project.exclude_globs().contains(&"/Obj".to_owned()));
|
||||
|
||||
// Project metadata refreshes without reopening when Unity generates its solution.
|
||||
std::fs::write(root.join("Game.sln"), "").unwrap();
|
||||
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
|
||||
let unity = ops::project::info(&project).unity.unwrap();
|
||||
assert_eq!(unity.solutions, ["Game.sln"]);
|
||||
assert_eq!(unity.projects, ["Assembly-CSharp.csproj"]);
|
||||
|
||||
// Desktop commands and remote RPC share these host operations.
|
||||
let original = root.join("Assets/Scripts/Player.cs");
|
||||
let renamed = root.join("Assets/Scripts/Hero.cs");
|
||||
let copied = root.join("Assets/Scripts/HeroCopy.cs");
|
||||
let meta = |path: &std::path::Path| -> std::path::PathBuf {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".meta");
|
||||
name.into()
|
||||
};
|
||||
let original_metadata = "fileFormatVersion: 2\nguid: 11111111111111111111111111111111\n";
|
||||
std::fs::write(meta(&original), original_metadata).unwrap();
|
||||
ops::fs::rename(&host, original.clone(), renamed.clone()).await.unwrap();
|
||||
assert!(!original.exists() && !meta(&original).exists());
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
ops::fs::copy(&host, renamed.clone(), copied.clone()).await.unwrap();
|
||||
assert!(copied.exists());
|
||||
assert_ne!(std::fs::read_to_string(meta(&copied)).unwrap(), original_metadata);
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), original_metadata);
|
||||
host.close_project("main");
|
||||
|
||||
let ordinary = base.join("ConsoleApp");
|
||||
std::fs::create_dir(&ordinary).unwrap();
|
||||
std::fs::write(ordinary.join("App.csproj"), "").unwrap();
|
||||
let project = host.open_project("main", &ordinary).unwrap();
|
||||
assert!(ops::project::info(&project).unity.is_none());
|
||||
assert!(!project.exclude_globs().contains(&"/Library".to_owned()));
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
//! A day with Git on a `fixtures/` project, through the `ops` the app's commands call: an edit
|
||||
//! marked in the gutter, Local Changes and its diff, a commit in the log, a branch, a stash, and
|
||||
//! a merge that conflicts, resolved and committed. Open documents follow every step.
|
||||
|
||||
use ide_core::DocId;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use ide_vcs::{ChangeKind, CommitOptions, FileStatus, HunkKind, LogQuery};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
const WINDOW: &str = "main";
|
||||
/// The line every branch changes, in `src/lib.rs`.
|
||||
const LINE: &str = "pub fn format_price(cents: u64) -> String {";
|
||||
|
||||
fn git(root: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8(out.stdout).unwrap()
|
||||
}
|
||||
|
||||
fn copy_dir(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_dir(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Flow {
|
||||
host: Arc<Host>,
|
||||
root: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Flow {
|
||||
async fn new(fixture: &str) -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let root = base.join(fixture);
|
||||
copy_dir(&Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures").join(fixture), &root);
|
||||
git(&root, &["init", "-q", "-b", "main"]);
|
||||
for (key, value) in [
|
||||
("user.name", "Test"),
|
||||
("user.email", "[email protected]"),
|
||||
("commit.gpgsign", "false"),
|
||||
("core.autocrlf", "false"),
|
||||
] {
|
||||
git(&root, &["config", key, value]);
|
||||
}
|
||||
git(&root, &["add", "-A"]);
|
||||
git(&root, &["commit", "-q", "-m", "Initial commit"]);
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = ops::project::open(&host, WINDOW, &root).await.unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
Flow { host, root, _tmp: tmp }
|
||||
}
|
||||
|
||||
async fn open(&self, file: &str) -> DocId {
|
||||
ops::doc::open(&self.host, WINDOW, self.root.join(file)).await.unwrap().id
|
||||
}
|
||||
|
||||
async fn text(&self, doc: DocId) -> String {
|
||||
ops::doc::snapshot(&self.host, WINDOW, doc).await.unwrap().text
|
||||
}
|
||||
|
||||
/// Types `with` over `LINE` in the editor (unsaved).
|
||||
async fn edit_line(&self, doc: DocId, with: &str) {
|
||||
let text = self.text(doc).await;
|
||||
let current = text.lines().find(|l| l.starts_with("pub fn format_price")).unwrap().to_owned();
|
||||
let project = self.host.project(WINDOW).unwrap();
|
||||
project.documents.replace_all(doc, &text.replacen(¤t, with, 1)).unwrap();
|
||||
}
|
||||
|
||||
async fn save(&self, doc: DocId) {
|
||||
ops::doc::save(&self.host, WINDOW, doc).await.unwrap();
|
||||
}
|
||||
|
||||
async fn status(&self) -> Vec<FileStatus> {
|
||||
ops::vcs::status(&self.host, WINDOW).await.unwrap()
|
||||
}
|
||||
|
||||
async fn commit(&self, files: &[&str], message: &str) -> String {
|
||||
let paths = files.iter().map(|f| self.root.join(f)).collect();
|
||||
ops::vcs::vcs_stage(&self.host, WINDOW, paths).await.unwrap();
|
||||
let options = CommitOptions { run_hooks: true, ..Default::default() };
|
||||
ops::vcs::vcs_commit(&self.host, WINDOW, None, message.into(), options).await.unwrap()
|
||||
}
|
||||
|
||||
async fn head(&self) -> ide_vcs::Commit {
|
||||
let log = ops::vcs::vcs_log(&self.host, WINDOW, None, LogQuery::default()).await.unwrap();
|
||||
log.into_iter().next().expect("a commit")
|
||||
}
|
||||
}
|
||||
|
||||
fn changed<'a>(status: &'a [FileStatus], file: &str) -> Option<&'a FileStatus> {
|
||||
status.iter().find(|s| s.rel_path == file)
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn change_commit_branch_stash_and_merge_a_conflict() {
|
||||
let f = Flow::new("rust-app").await;
|
||||
let lib_path = f.root.join("src/lib.rs");
|
||||
let lib = f.open("src/lib.rs").await;
|
||||
let original = f.text(lib).await;
|
||||
let line = original.lines().position(|l| l == LINE).expect("the fixture's line") as u32;
|
||||
|
||||
// An edit in the editor: a modified line in the gutter, against HEAD, before saving.
|
||||
let base = "pub fn format_price(cents: u64) -> String { // base";
|
||||
f.edit_line(lib, base).await;
|
||||
let hunks = ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
assert!(
|
||||
matches!(&hunks[..], [h] if h.kind == HunkKind::Modified && h.start == line && h.old_text.contains(LINE)),
|
||||
"{hunks:?}"
|
||||
);
|
||||
assert!(f.status().await.is_empty(), "nothing saved yet");
|
||||
|
||||
// Saved: Local Changes lists it, and its diff shows the edit.
|
||||
f.save(lib).await;
|
||||
let status = f.status().await;
|
||||
assert_eq!(changed(&status, "src/lib.rs").and_then(|s| s.unstaged), Some(ChangeKind::Modified), "{status:?}");
|
||||
let diff = ops::vcs::vcs_diff_text(&f.host, WINDOW, lib_path.clone(), false).await.unwrap();
|
||||
assert!(diff.contains(&format!("-{LINE}")) && diff.contains(&format!("+{base}")), "{diff}");
|
||||
|
||||
// Commit: the log's head; nothing left to commit; no gutter marks.
|
||||
let id = f.commit(&["src/lib.rs"], "Mark the base").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!((head.id.as_str(), head.summary.as_str()), (id.as_str(), "Mark the base"));
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(ops::vcs::vcs_line_hunks(&f.host, WINDOW, lib_path.clone()).await.unwrap().is_empty());
|
||||
|
||||
// A branch changes the line its way.
|
||||
ops::vcs::vcs_create_branch(&f.host, WINDOW, None, "feature".into(), None, true).await.unwrap();
|
||||
let theirs = "pub fn format_price(cents: u64) -> String { // feature";
|
||||
f.edit_line(lib, theirs).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Feature's change").await;
|
||||
let branches = ops::vcs::vcs_branches(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(branches.iter().any(|b| b.name == "feature" && b.current), "{branches:?}");
|
||||
|
||||
// Back on main, the open file shows main's text.
|
||||
ops::vcs::vcs_checkout(&f.host, WINDOW, None, "main".into(), false, None).await.unwrap();
|
||||
assert!(f.text(lib).await.contains(base));
|
||||
|
||||
// Stash: work in progress put aside and brought back.
|
||||
let main_rs = f.open("src/main.rs").await;
|
||||
let wip = format!("{}// work in progress\n", f.text(main_rs).await);
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(main_rs, &wip).unwrap();
|
||||
f.save(main_rs).await;
|
||||
ops::vcs::vcs_stash_push(&f.host, WINDOW, None, Some("wip".into()), false, false).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
assert!(!f.text(main_rs).await.contains("work in progress"));
|
||||
let stashes = ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap();
|
||||
assert!(matches!(&stashes[..], [s] if s.message.contains("wip")), "{stashes:?}");
|
||||
ops::vcs::vcs_stash_apply(&f.host, WINDOW, None, 0, true, false, None).await.unwrap();
|
||||
assert_eq!(f.text(main_rs).await, wip);
|
||||
assert!(ops::vcs::vcs_stashes(&f.host, WINDOW, None).await.unwrap().is_empty());
|
||||
ops::vcs::vcs_discard(&f.host, WINDOW, vec![f.root.join("src/main.rs")]).await.unwrap();
|
||||
assert!(f.status().await.is_empty());
|
||||
|
||||
// Main changes the same line: merging the branch conflicts.
|
||||
let ours = "pub fn format_price(cents: u64) -> String { // main";
|
||||
f.edit_line(lib, ours).await;
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Main's change").await;
|
||||
let merged = ops::vcs::vcs_merge(&f.host, WINDOW, None, "feature".into()).await;
|
||||
let status = f.status().await;
|
||||
let conflicted = changed(&status, "src/lib.rs").map(|s| (s.staged, s.unstaged));
|
||||
assert!(
|
||||
matches!(conflicted, Some((Some(ChangeKind::Conflicted), _) | (_, Some(ChangeKind::Conflicted)))),
|
||||
"merge: {merged:?}; status: {status:?}"
|
||||
);
|
||||
// The open file shows the conflict as Git left it.
|
||||
assert!(f.text(lib).await.contains("<<<<<<<"), "{}", f.text(lib).await);
|
||||
let versions = ops::vcs::vcs_conflict_versions(&f.host, WINDOW, lib_path.clone()).await.unwrap();
|
||||
let has = |side: &Option<String>, line: &str| side.as_deref().is_some_and(|t| t.contains(line));
|
||||
assert!(has(&versions.base, base) && has(&versions.ours, ours) && has(&versions.theirs, theirs), "{versions:?}");
|
||||
|
||||
// Resolved (Accept Theirs) and committed: a merge commit, nothing left.
|
||||
let resolved = versions.theirs.clone().unwrap();
|
||||
f.host.project(WINDOW).unwrap().documents.replace_all(lib, &resolved).unwrap();
|
||||
f.save(lib).await;
|
||||
f.commit(&["src/lib.rs"], "Merge feature").await;
|
||||
let head = f.head().await;
|
||||
assert_eq!(head.parents.len(), 2, "{head:?}");
|
||||
assert!(f.status().await.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&lib_path).unwrap(), resolved);
|
||||
}
|
||||
@@ -175,3 +175,66 @@ async fn contended_version_reads_do_not_block_the_async_worker() {
|
||||
assert!(watchdog.join().unwrap(), "request {request_kind} blocked the only async worker");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn code_actions_skip_a_command_no_server_provides_after_their_edit() {
|
||||
use ide_lsp::types::{CodeAction, Command};
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("imports.txt");
|
||||
std::fs::write(&file, "unused\nused\n").unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("local-data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(|_| {})),
|
||||
owner: |window| window,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.open_light("main", &file).unwrap();
|
||||
let info = ops::doc::open(&host, "main", file.clone()).await.unwrap();
|
||||
// Optimize Imports as vtsls offers it: the edit, then a command its client runs itself.
|
||||
let action = |edit: bool| CodeAction {
|
||||
title: "Organize Imports".into(),
|
||||
kind: Some("source.organizeImports".into()),
|
||||
is_preferred: false,
|
||||
disabled_reason: None,
|
||||
edit: edit.then(|| {
|
||||
serde_json::from_value(json!({ "files": [{ "kind": "edit", "path": file, "edits": [
|
||||
{ "range": { "start": { "line": 0, "col": 0 }, "end": { "line": 1, "col": 0 } }, "new_text": "" }
|
||||
] }] }))
|
||||
.unwrap()
|
||||
}),
|
||||
command: Some(Command {
|
||||
title: String::new(),
|
||||
command: "_typescript.didOrganizeImports".into(),
|
||||
arguments: vec![],
|
||||
}),
|
||||
data: None,
|
||||
server: ide_core::ServerId(1),
|
||||
};
|
||||
let follow_up = action(true).command.unwrap();
|
||||
let asked = ops::lsp::lsp_resolve_command(
|
||||
&host,
|
||||
"main",
|
||||
ide_core::RequestId(1),
|
||||
info.id,
|
||||
ide_core::ServerId(1),
|
||||
follow_up,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(asked, Ok(None)), "{asked:?}");
|
||||
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(2), info.id, action(true), vec![])
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "used\n");
|
||||
ops::lsp::lsp_apply_code_action(&host, "main", info.id, action(true)).await.unwrap();
|
||||
assert_eq!(ops::doc::snapshot(&host, "main", info.id).await.unwrap().text, "");
|
||||
// Without an edit the command is the action: it still can't run.
|
||||
let ran =
|
||||
ops::lsp::lsp_execute_code_action(&host, "main", ide_core::RequestId(3), info.id, action(false), vec![]).await;
|
||||
assert!(ran.is_err(), "{ran:?}");
|
||||
}
|
||||
@@ -114,3 +114,54 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1);
|
||||
host.close_project("main");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn php_language_level_follows_the_member_of_the_file() {
|
||||
use ide_sdk::php::{PhpLanguageLevel, PhpLevelSource};
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let (api, legacy) = (base.join("api"), base.join("legacy"));
|
||||
for (member, composer) in [
|
||||
(&api, r#"{"require": {"php": "^7.4"}}"#),
|
||||
(&legacy, r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.0.5"}}}"#),
|
||||
] {
|
||||
std::fs::create_dir(member).unwrap();
|
||||
std::fs::write(member.join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let root = base.join("workspace");
|
||||
Workspace::save(&root, "PHP", vec![api.clone(), legacy.clone()], true).unwrap();
|
||||
std::fs::write(root.join("composer.json"), r#"{"require": {"php": "^8.2"}}"#).unwrap();
|
||||
let config_dir = base.join("config");
|
||||
ide_workspace::SettingsStore::load(&config_dir, Some(&legacy))
|
||||
.set(ide_workspace::SettingsScope::Project, "languages.php.languageLevel", "8.3".into())
|
||||
.unwrap();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir: base.join("data"),
|
||||
local_data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let level = async |path: std::path::PathBuf| ops::sdk::sdk_php_language_level(&host, "main", path).await.unwrap();
|
||||
|
||||
let level_of_api = level(api.join("src/Invoice.php")).await;
|
||||
assert_eq!(level_of_api.effective, found("7.4.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_of_api.auto, level_of_api.effective);
|
||||
assert_eq!(level_of_api.member.as_deref(), Some(api.as_path()));
|
||||
// The member's own settings, not the workspace's.
|
||||
let level_of_legacy = level(legacy.join("index.php")).await;
|
||||
assert_eq!(level_of_legacy.effective, found("8.3.0", PhpLevelSource::Settings));
|
||||
assert_eq!(level_of_legacy.auto, found("8.0.5", PhpLevelSource::Platform));
|
||||
// Outside every member: the workspace's own.
|
||||
let level_elsewhere = level(base.join("elsewhere.php")).await;
|
||||
assert_eq!(level_elsewhere.effective, found("8.2.0", PhpLevelSource::Require));
|
||||
assert_eq!(level_elsewhere.member, None);
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use ide_language::{LanguageRegistry, Syntax};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -64,6 +65,14 @@ pub struct IndexStatus {
|
||||
pub symbols: u32,
|
||||
}
|
||||
|
||||
/// How many indexed files share an extension (lower case, without the dot).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtensionCount {
|
||||
pub extension: String,
|
||||
pub files: u32,
|
||||
}
|
||||
|
||||
pub struct ProjectIndex {
|
||||
shared: Arc<Shared>,
|
||||
worker: Mutex<Option<JoinHandle<()>>>,
|
||||
@@ -159,6 +168,27 @@ impl ProjectIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// The extensions of the indexed files, most common first: the project's file
|
||||
/// types, for suggestions such as Find in Files' file masks. Dotfiles
|
||||
/// (`.gitignore`) have no extension.
|
||||
pub fn file_extensions(&self) -> Vec<ExtensionCount> {
|
||||
let d = self.shared.data.read();
|
||||
let mut counts: HashMap<String, u32> = HashMap::new();
|
||||
for file in d.files.iter().flatten() {
|
||||
let name = &file.rel[file.name_start as usize..];
|
||||
if let Some((stem, extension)) = name.rsplit_once('.')
|
||||
&& !stem.is_empty()
|
||||
&& !extension.is_empty()
|
||||
{
|
||||
*counts.entry(extension.to_ascii_lowercase()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
let mut out: Vec<ExtensionCount> =
|
||||
counts.into_iter().map(|(extension, files)| ExtensionCount { extension, files }).collect();
|
||||
out.sort_by(|a, b| b.files.cmp(&a.files).then_with(|| a.extension.cmp(&b.extension)));
|
||||
out
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
|
||||
@@ -111,6 +111,28 @@ fn indexes_files_and_symbols_honouring_excludes() {
|
||||
assert_eq!(m.path, fx.root.join("src/main.rs"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn counts_file_extensions_most_common_first() {
|
||||
let fx = Fixture::new(&[
|
||||
("src/a.rs", "fn a() {}\n"),
|
||||
("src/b.RS", "fn b() {}\n"),
|
||||
("src/lib.rs", "fn c() {}\n"),
|
||||
("Cargo.toml", "[package]\n"),
|
||||
("README.md", "# r\n"),
|
||||
("docs/guide.md", "# g\n"),
|
||||
(".gitignore", "target\n"),
|
||||
("Makefile", "all:\n"),
|
||||
("target/debug/gen.rs", "fn generated() {}\n"),
|
||||
]);
|
||||
let idx = fx.open();
|
||||
let counts: Vec<(String, u32)> = idx.file_extensions().into_iter().map(|c| (c.extension, c.files)).collect();
|
||||
assert_eq!(
|
||||
counts,
|
||||
[("rs".into(), 3), ("md".into(), 2), ("toml".into(), 1)],
|
||||
"case-folded, excluded folders and extension-less files left out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_ranking_is_idea_like() {
|
||||
let fx = sample();
|
||||
|
||||
@@ -23,6 +23,7 @@ mod scope;
|
||||
mod server;
|
||||
mod tcp;
|
||||
mod text;
|
||||
mod ts_file_moves;
|
||||
mod tsserver_bridge;
|
||||
pub mod types;
|
||||
mod uri;
|
||||
@@ -1692,6 +1693,20 @@ impl LspManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a server of `doc` (`server`, when given) provides the command `name`. Code actions
|
||||
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
|
||||
/// `_typescript.didOrganizeImports`): no server provides those.
|
||||
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
|
||||
let servers = match self.snapshot(doc) {
|
||||
Ok(snap) => snap.servers,
|
||||
Err(_) => self.servers.lock().clone(),
|
||||
};
|
||||
self.ready_targets(&servers, |c| c.commands().iter().any(|c| c == name))
|
||||
.await
|
||||
.iter()
|
||||
.any(|t| server.is_none_or(|s| t.server.id == s))
|
||||
}
|
||||
|
||||
async fn command_target(&self, doc: DocId, server: ide_core::ServerId, name: &str) -> IdeResult<Target> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
self.ready_targets(&snap.servers, |c| c.commands().iter().any(|c| c == name))
|
||||
@@ -1710,6 +1725,10 @@ impl LspManager {
|
||||
answers: Vec<FormAnswer>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Option<CommandForm>> {
|
||||
// Nothing to ask for a command its server doesn't provide (executing it decides).
|
||||
if !self.provides_command(doc, Some(server), &command.command).await {
|
||||
return Ok(None);
|
||||
}
|
||||
let t = self.command_target(doc, server, &command.command).await?;
|
||||
let interactive = t.server.name == "gopls"
|
||||
&& t.caps()
|
||||
@@ -1992,7 +2011,30 @@ impl LspManager {
|
||||
servers.iter().zip(ready).filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? })).collect();
|
||||
let mut out = FileRenameEdits::default();
|
||||
for t in targets {
|
||||
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else { continue };
|
||||
let Some(params) = Self::file_op_params(&t, "willRename", &moves) else {
|
||||
if t.server.config.tsserver_project && t.caps().commands().iter().any(|c| c == ts_file_moves::COMMAND) {
|
||||
out.servers.push(t.server.name.clone());
|
||||
let opened = self.open_for_file_op(&t, &moves);
|
||||
for (from, to, _) in &moves {
|
||||
let r: IdeResult<Value> = self
|
||||
.call(
|
||||
&t,
|
||||
"workspace/executeCommand",
|
||||
ts_file_moves::request(from, to),
|
||||
Some(&cancel),
|
||||
LONG_TIMEOUT,
|
||||
)
|
||||
.await;
|
||||
match r {
|
||||
Ok(result) => out.edit.files.extend(ts_file_moves::edit(&result).files),
|
||||
Err(e) if e.kind == ErrorKind::Cancelled && cancel.is_cancelled() => return Err(e),
|
||||
Err(e) => out.errors.push(e.message),
|
||||
}
|
||||
}
|
||||
drop(opened);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
out.servers.push(t.server.name.clone());
|
||||
let opened = self.open_for_file_op(&t, &moves);
|
||||
let r: IdeResult<Option<lsp::WorkspaceEdit>> =
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
//! Reference updates for moved files from vtsls, which has no `workspace/willRenameFiles`:
|
||||
//! it updates imports itself after `didRenameFiles`, behind a prompt the client answers. The
|
||||
//! IDE asks tsserver for the same edits up front (`getEditsForFileRename`, through
|
||||
//! `typescript.tsserverRequest`), so they join the move's preview and undo, and the server's
|
||||
//! own update is off (`updateImportsOnFileMove.enabled = "never"` in `languages/*.toml`).
|
||||
|
||||
use ide_core::{FileEdit, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) const COMMAND: &str = "typescript.tsserverRequest";
|
||||
|
||||
/// The `typescript.tsserverRequest` arguments asking for the edits of moving `from` to `to`.
|
||||
pub(crate) fn request(from: &std::path::Path, to: &std::path::Path) -> Value {
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": ["getEditsForFileRename", { "oldFilePath": from, "newFilePath": to }],
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Location {
|
||||
line: u32,
|
||||
offset: u32,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Change {
|
||||
start: Location,
|
||||
end: Location,
|
||||
new_text: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct FileChanges {
|
||||
file_name: PathBuf,
|
||||
text_changes: Vec<Change>,
|
||||
}
|
||||
|
||||
/// tsserver's `FileCodeEdits[]` (the command's `body`; 1-based lines and UTF-16 offsets) as an edit.
|
||||
pub(crate) fn edit(result: &Value) -> WorkspaceEdit {
|
||||
let body = result.get("body").unwrap_or(result);
|
||||
let files: Vec<FileChanges> = serde_json::from_value(body.clone()).unwrap_or_default();
|
||||
let pos = |l: &Location| Pos::new(l.line.saturating_sub(1), l.offset.saturating_sub(1));
|
||||
WorkspaceEdit {
|
||||
files: files
|
||||
.into_iter()
|
||||
.filter(|f| !f.text_changes.is_empty())
|
||||
.map(|f| FileEdit::Edit {
|
||||
path: f.file_name.into(),
|
||||
edits: f
|
||||
.text_changes
|
||||
.into_iter()
|
||||
.map(|c| TextEdit { range: Range::new(pos(&c.start), pos(&c.end)), new_text: c.new_text })
|
||||
.collect(),
|
||||
version: None,
|
||||
})
|
||||
.collect(),
|
||||
preconditions: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn converts_tsserver_file_code_edits() {
|
||||
let result = json!({ "success": true, "body": [
|
||||
{ "fileName": "/p/src/index.ts", "textChanges": [
|
||||
{ "start": { "line": 2, "offset": 31 }, "end": { "line": 2, "offset": 41 }, "newText": "./lib/money.ts" }
|
||||
] },
|
||||
{ "fileName": "/p/src/unchanged.ts", "textChanges": [] }
|
||||
] });
|
||||
let edit = edit(&result);
|
||||
assert_eq!(edit.files.len(), 1);
|
||||
let FileEdit::Edit { path, edits, version } = &edit.files[0] else { panic!("{edit:?}") };
|
||||
assert_eq!(**path, *std::path::Path::new("/p/src/index.ts"));
|
||||
assert_eq!(
|
||||
edits,
|
||||
&[TextEdit { range: Range::new(Pos::new(1, 30), Pos::new(1, 40)), new_text: "./lib/money.ts".into() }]
|
||||
);
|
||||
assert!(version.is_none());
|
||||
assert!(super::edit(&json!({ "success": false })).files.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -2336,6 +2336,41 @@ async fn will_and_did_rename_files_honour_filters() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn will_rename_files_asks_tsserver_when_the_server_has_no_file_operations() {
|
||||
// vtsls: no `willRename` filters, but tsserver's `getEditsForFileRename` through its command.
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": ["typescript.tsserverRequest"] });
|
||||
let h = harness_configured("typescript", "ts", &[("vtsls", caps)], |cfg| cfg.tsserver_project = true);
|
||||
let money = h.root.join("money.ts");
|
||||
let index = h.root.join("index.ts");
|
||||
std::fs::write(&money, "export const one = 1;\n").unwrap();
|
||||
std::fs::write(&index, "import { one } from \"./money\";\n").unwrap();
|
||||
let s = h.launcher.state("vtsls");
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, p| {
|
||||
(method == "workspace/executeCommand" && p["arguments"][0] == "getEditsForFileRename").then(|| {
|
||||
json!({ "success": true, "body": [{ "fileName": p["arguments"][1]["oldFilePath"].as_str().unwrap().replace("money.ts", "index.ts"),
|
||||
"textChanges": [{ "start": { "line": 1, "offset": 22 }, "end": { "line": 1, "offset": 29 }, "newText": "./lib/money" }] }] })
|
||||
})
|
||||
}));
|
||||
let to = h.root.join("lib/money.ts");
|
||||
let result =
|
||||
h.mgr.will_rename_files(&[(money.clone(), to.clone())], ide_core::CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(result.servers, vec!["vtsls".to_owned()]);
|
||||
assert!(result.errors.is_empty(), "{:?}", result.errors);
|
||||
match &result.edit.files[..] {
|
||||
[FileEdit::Edit { path, edits, version: None }] => {
|
||||
assert_eq!(path, &index);
|
||||
assert_eq!(edits[0].range, rng(0, 21, 0, 28));
|
||||
assert_eq!(edits[0].new_text, "./lib/money");
|
||||
}
|
||||
other => panic!("unexpected edit {other:?}"),
|
||||
}
|
||||
let asked = s.requested("workspace/executeCommand");
|
||||
let asked = asked.iter().find(|r| r["arguments"][0] == "getEditsForFileRename").unwrap();
|
||||
assert_eq!(asked["arguments"][1], json!({ "oldFilePath": money, "newFilePath": to }));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ inspect code
|
||||
|
||||
struct InspectTestHost {
|
||||
|
||||
@@ -105,6 +105,7 @@ pub trait HostApi: Send + Sync {
|
||||
args: Vec<String>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
}
|
||||
|
||||
@@ -640,6 +640,21 @@ impl views::Host for StoreData {
|
||||
let title = clip(title, crate::runtime::MAX_TITLE);
|
||||
Ok(self.host.open_terminal(client.as_deref(), title, &program, args, cwd.as_deref()).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn open_location(&mut self, path: String, line: u32, column: u32) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let Some(root) = self.invocation.workspace_root.clone() else {
|
||||
return Ok(Err(wit::Error::Invalid("no project is open in this window".into())));
|
||||
};
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
};
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.open_location(client.as_deref(), &real, line, column).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
impl settings::Host for StoreData {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a50d1eb41ab8dd0c9c8d6bae8266906cd6eb5de8e6f6ca6f681990389472bb5b
|
||||
1f7402e2b1b4602e4073eb61837c3dc8840f9a4f7275d800dbd6fcf6ce5e40c3
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
2fb6aa879562733bf5b7f11dabe8fc6a415c9ef695868fe7845417a4245d2dc8
|
||||
4f6cd5b5e0c12e7dcbccf66a6ad97276bf968bc3ad26f0fd08e1f7765cbfee14
|
||||
@@ -12,6 +12,7 @@ activation = ["on-view:jobs", "on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["process:jobs"]
|
||||
optional = ["project.read"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "jobs"
|
||||
|
||||
@@ -64,6 +64,8 @@ impl Views for Jobs {
|
||||
return self.follow();
|
||||
}
|
||||
ViewEvent::Button(b) if b == "follow" => return self.follow(),
|
||||
ViewEvent::Button(b) if b == "log" => return host::open_location("jobs.log", 2, 4),
|
||||
ViewEvent::Button(b) if b == "outside" => return host::open_location("../outside.log", 0, 0),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
host::refresh_view(VIEW);
|
||||
|
||||
@@ -1 +1 @@
|
||||
25b0f419a68a3b1651d3c2774d834b05e40ede52bc5ed8c0eba6d27a204efe82
|
||||
3d4dd1b6365c7fbafdd3c1f99f4405e5ee40fc5e56c97da960adfe7af2e7edb8
|
||||
@@ -143,7 +143,10 @@ impl Mappings {
|
||||
}
|
||||
let (l, rest, _) = best?;
|
||||
// A `..` from the server must not leave the local folder.
|
||||
if rest.split('/').any(|s| s == ".." || s == ".") {
|
||||
if rest.split('/').filter(|s| !s.is_empty()).any(|s| {
|
||||
let mut components = Path::new(s).components();
|
||||
!matches!(components.next(), Some(Component::Normal(_))) || components.next().is_some()
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
|
||||
@@ -307,6 +310,43 @@ mod tests {
|
||||
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn native_anchors_never_replace_the_project_relative_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let inside = root.join("src");
|
||||
let outside = tmp.path().join("outside");
|
||||
let missing = tmp.path().join("missing");
|
||||
std::fs::create_dir_all(&inside).unwrap();
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
for slash in ["\\", "/"] {
|
||||
let spelling = |path: &Path| path.to_string_lossy().replace('\\', slash);
|
||||
assert_eq!(local_folder(&spelling(&inside), &root), Some(inside.clone()));
|
||||
assert_eq!(local_folder(&spelling(&outside), &root), None);
|
||||
assert_eq!(local_folder(&spelling(&missing), &root), Some(root.join(posix(&missing))));
|
||||
assert_eq!(local_folder(&format!("{slash}src{slash}"), &root), Some(inside.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_paths_cannot_replace_or_leave_a_local_mapping() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let m = Mappings::new(&server(vec![]), root, "/srv");
|
||||
for remote in ["/srv/../outside", "/srv/./file"] {
|
||||
assert_eq!(m.local(remote), None, "{remote}");
|
||||
}
|
||||
#[cfg(windows)]
|
||||
for remote in
|
||||
["/srv/..\\outside", "/srv/C:\\outside", "/srv/\\outside", "/srv/dir\\..\\outside", "/srv/C:/outside"]
|
||||
{
|
||||
assert_eq!(m.local(remote), None, "{remote}");
|
||||
}
|
||||
assert_eq!(m.local("/srv/dir/file"), Some(root.join("dir/file")));
|
||||
assert_eq!(m.local("/srv"), Some(root.to_path_buf()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn absolute_symlink_inside_project_resolves_to_the_project_spelling() {
|
||||
|
||||
+82
-10
@@ -5,7 +5,7 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{angular, godot, pinned, vue};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
|
||||
use ide_core::version_managers::Tool;
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
@@ -442,15 +442,41 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor › Inspections › Java: "Serializable class without 'serialVersionUID'" (off by default,
|
||||
/// as in IntelliJ IDEA). ECJ reports it, so it is a compiler option rather than a filter.
|
||||
pub const SERIAL_VERSION_INSPECTION: &str = "java:missing-serial-version";
|
||||
|
||||
/// ECJ's level for a missing `serialVersionUID`, from the inspection profile in effect.
|
||||
fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static str> {
|
||||
let o = inspections.active().get(SERIAL_VERSION_INSPECTION)?;
|
||||
if o.enabled != Some(true) {
|
||||
return None;
|
||||
}
|
||||
Some(match o.severity {
|
||||
Some(InspectionLevel::Error) => "error",
|
||||
None | Some(InspectionLevel::Warning) => "warning",
|
||||
Some(_) => "info",
|
||||
})
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(content.as_str()) {
|
||||
ide_vfs::write_atomic(&file, content.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
@@ -612,9 +638,18 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
|
||||
// Intelephense reads it as `intelephense.environment.phpVersion` (`workspace/configuration`).
|
||||
if name == "intelephense"
|
||||
&& let Some(version) = crate::php::language_level(root, s)
|
||||
&& let Some(level) = crate::php::language_level(root, s)
|
||||
{
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": version } }));
|
||||
merge_init_options(cfg, json!({ "environment": { "phpVersion": level.version } }));
|
||||
}
|
||||
if name == "intelephense" {
|
||||
if let Some(stubs) = crate::php::intelephense_stubs(root, s) {
|
||||
merge_init_options(cfg, json!({ "stubs": stubs }));
|
||||
}
|
||||
let include_paths = crate::php::include_paths(root, s);
|
||||
if !include_paths.is_empty() {
|
||||
merge_init_options(cfg, json!({ "environment": { "includePaths": include_paths } }));
|
||||
}
|
||||
}
|
||||
|
||||
// Node-based servers (`#!/usr/bin/env node`) run on the selected Node.js.
|
||||
@@ -1086,13 +1121,50 @@ mod java_compiler_tests {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serial_version_inspection_sets_the_compiler_option() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let url = |inspections: &InspectionSettings| {
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), inspections);
|
||||
options(&cfg).pointer("/settings/java/settings/url").unwrap().as_str().unwrap().to_owned()
|
||||
};
|
||||
let set = |o: ide_core::settings::InspectionOverride| {
|
||||
let mut s = InspectionSettings::default();
|
||||
s.default.insert(SERIAL_VERSION_INSPECTION.into(), o);
|
||||
s
|
||||
};
|
||||
let off = url(&InspectionSettings::default());
|
||||
assert!(!std::fs::read_to_string(&off).unwrap().contains("missingSerialVersion"));
|
||||
let on = url(&set(ide_core::settings::InspectionOverride { enabled: Some(true), ..Default::default() }));
|
||||
assert_ne!(on, off, "a new file, so the server restarts with it");
|
||||
assert!(std::fs::read_to_string(&on).unwrap().ends_with("compiler.problem.missingSerialVersion=warning\n"));
|
||||
let error = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(error).unwrap().ends_with("missingSerialVersion=error\n"));
|
||||
let weak = url(&set(ide_core::settings::InspectionOverride {
|
||||
enabled: Some(true),
|
||||
severity: Some(InspectionLevel::WeakWarning),
|
||||
..Default::default()
|
||||
}));
|
||||
assert!(std::fs::read_to_string(weak).unwrap().ends_with("missingSerialVersion=info\n"));
|
||||
let severity_only = url(&set(ide_core::settings::InspectionOverride {
|
||||
severity: Some(InspectionLevel::Error),
|
||||
..Default::default()
|
||||
}));
|
||||
assert_eq!(severity_only, off, "off by default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
@@ -1103,7 +1175,7 @@ mod java_compiler_tests {
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
configure_java_compiler(&mut cfg, &cache, &InspectionSettings::default());
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
@@ -1113,7 +1185,7 @@ mod java_compiler_tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
configure_java_compiler(&mut cfg, tmp.path(), &InspectionSettings::default());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
|
||||
@@ -369,7 +369,6 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
+177
-11
@@ -1,24 +1,53 @@
|
||||
//! The PHP language level: the one chosen in Settings › PHP, else the one the root
|
||||
//! `composer.json` declares (`config.platform.php`, else the lowest version `require.php` allows);
|
||||
//! and the Intelephense storage that goes with it.
|
||||
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
|
||||
|
||||
use crate::apply::{merge, merge_init_options};
|
||||
use crate::apply::{merge, merge_init_options, setting_path};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::Path;
|
||||
|
||||
/// The project's PHP version as `X.Y.Z`; `None` when neither the settings nor
|
||||
/// `composer.json` name one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<String> {
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PhpLanguageLevel {
|
||||
/// `X.Y.Z`.
|
||||
pub version: String,
|
||||
pub source: PhpLevelSource,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum PhpLevelSource {
|
||||
/// `languages.php.languageLevel`.
|
||||
Settings,
|
||||
/// `config.platform.php` of `composer.json`.
|
||||
Platform,
|
||||
/// The lowest version `require.php` of `composer.json` allows.
|
||||
Require,
|
||||
}
|
||||
|
||||
/// The project's PHP version: the one chosen in the settings, else [`composer_level`];
|
||||
/// `None` when neither names one.
|
||||
pub fn language_level(root: &Path, s: &Settings) -> Option<PhpLanguageLevel> {
|
||||
let explicit = s.languages.php.language_level.trim();
|
||||
if !explicit.is_empty() {
|
||||
return version(explicit).map(dotted);
|
||||
return version(explicit).map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Settings));
|
||||
}
|
||||
composer_level(root)
|
||||
}
|
||||
|
||||
/// The PHP version the root `composer.json` declares (the setting's Auto):
|
||||
/// `config.platform.php`, else the lowest version `require.php` allows.
|
||||
pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
|
||||
let text = std::fs::read_to_string(root.join("composer.json")).ok()?;
|
||||
let composer: Value = serde_json::from_str(&text).ok()?;
|
||||
let platform = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version);
|
||||
platform.or_else(|| lowest_allowed(composer.pointer("/require/php")?.as_str()?)).map(dotted)
|
||||
if let Some(platform) = composer.pointer("/config/platform/php").and_then(Value::as_str).and_then(version) {
|
||||
return Some(PhpLanguageLevel::new(platform, PhpLevelSource::Platform));
|
||||
}
|
||||
lowest_allowed(composer.pointer("/require/php")?.as_str()?)
|
||||
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
|
||||
}
|
||||
|
||||
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
|
||||
@@ -48,10 +77,74 @@ pub fn configure_intelephense_storage(cfg: &mut LanguageServerConfig, data_dir:
|
||||
merge_init_options(cfg, json!({ "storagePath": storage.to_string_lossy() }));
|
||||
}
|
||||
|
||||
/// The stub sets Intelephense loads when `stubs` is not set (Intelephense 1.18.5). Setting
|
||||
/// `stubs` replaces them, so additions are sent together with these.
|
||||
#[rustfmt::skip]
|
||||
const DEFAULT_STUBS: &[&str] = &[
|
||||
"apache", "bcmath", "bz2", "calendar", "com_dotnet", "Core", "ctype", "curl", "date", "dba", "dom", "enchant",
|
||||
"exif", "fileinfo", "filter", "fpm", "ftp", "gd", "hash", "iconv", "imap", "intl", "json", "ldap", "libxml",
|
||||
"mbstring", "mcrypt", "mssql", "mysqli", "oci8", "odbc", "openssl", "pcntl", "pcre", "PDO", "pgsql", "Phar",
|
||||
"posix", "pspell", "random", "readline", "Reflection", "regex", "session", "shmop", "SimpleXML", "snmp", "soap",
|
||||
"sockets", "sodium", "SPL", "sqlite3", "standard", "superglobals", "sybase", "sysvmsg", "sysvsem", "sysvshm",
|
||||
"tidy", "tokenizer", "uri", "xml", "xmlreader", "xmlrpc", "xmlwriter", "Zend OPcache", "zip", "zlib",
|
||||
];
|
||||
|
||||
/// Stub sets outside the defaults whose names are not lower case; the rest are the lower-case
|
||||
/// extension name.
|
||||
const MIXED_CASE_STUBS: &[&str] =
|
||||
&["Ev", "FFI", "LuaSandbox", "Parle", "SQLite", "SaxonC", "SplType", "ZendCache", "ZendDebugger", "ZendUtils"];
|
||||
|
||||
/// `Zend OPcache`, `zend-opcache` and `zend_opcache` name the same stubs.
|
||||
fn stub_key(name: &str) -> String {
|
||||
name.chars().filter(char::is_ascii_alphanumeric).map(|c| c.to_ascii_lowercase()).collect()
|
||||
}
|
||||
|
||||
/// The `stubs` Intelephense loads: its defaults plus a stub set for each `ext-*` that the root
|
||||
/// `composer.json` requires (`require`, `require-dev`) and those in Settings › PHP. `None` when
|
||||
/// that adds nothing to the defaults, so the server keeps its own.
|
||||
pub fn intelephense_stubs(root: &Path, s: &Settings) -> Option<Vec<String>> {
|
||||
let composer: Option<Value> =
|
||||
std::fs::read_to_string(root.join("composer.json")).ok().and_then(|t| serde_json::from_str(&t).ok());
|
||||
let extensions = ["require", "require-dev"]
|
||||
.into_iter()
|
||||
.filter_map(|section| composer.as_ref()?.get(section)?.as_object())
|
||||
.flat_map(|deps| deps.keys())
|
||||
.filter_map(|dep| dep.strip_prefix("ext-"))
|
||||
.map(|ext| {
|
||||
let key = stub_key(ext);
|
||||
MIXED_CASE_STUBS
|
||||
.iter()
|
||||
.find(|stub| stub_key(stub) == key)
|
||||
.map_or(ext.to_ascii_lowercase(), |s| s.to_string())
|
||||
});
|
||||
let configured = s.languages.php.stubs.iter().map(|stub| stub.trim().to_owned());
|
||||
let mut stubs: Vec<String> = DEFAULT_STUBS.iter().map(|s| s.to_string()).collect();
|
||||
let mut known: std::collections::HashSet<String> = stubs.iter().map(|s| stub_key(s)).collect();
|
||||
for stub in extensions.chain(configured) {
|
||||
if !stub.is_empty() && known.insert(stub_key(&stub)) {
|
||||
stubs.push(stub);
|
||||
}
|
||||
}
|
||||
(stubs.len() > DEFAULT_STUBS.len()).then_some(stubs)
|
||||
}
|
||||
|
||||
/// The folders in Settings › PHP › Include paths, resolved against the project.
|
||||
pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
|
||||
s.languages
|
||||
.php
|
||||
.include_paths
|
||||
.iter()
|
||||
.filter_map(|p| setting_path(root, p))
|
||||
.map(|p| p.to_string_lossy().into_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
type Version = (u32, u32, u32);
|
||||
|
||||
fn dotted((major, minor, patch): Version) -> String {
|
||||
format!("{major}.{minor}.{patch}")
|
||||
impl PhpLanguageLevel {
|
||||
fn new((major, minor, patch): Version, source: PhpLevelSource) -> Self {
|
||||
Self { version: format!("{major}.{minor}.{patch}"), source }
|
||||
}
|
||||
}
|
||||
|
||||
/// `7`, `7.4`, `v7.4.3`, `8.1.*`, `7.4.0-dev`: missing parts are 0.
|
||||
@@ -105,13 +198,18 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
|
||||
levels(composer, explicit).0.map(|l| l.version)
|
||||
}
|
||||
|
||||
/// The effective level and the composer one.
|
||||
fn levels(composer: Option<&str>, explicit: &str) -> (Option<PhpLanguageLevel>, Option<PhpLanguageLevel>) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.language_level = explicit.into();
|
||||
language_level(tmp.path(), &s)
|
||||
(language_level(tmp.path(), &s), composer_level(tmp.path()))
|
||||
}
|
||||
|
||||
fn required(constraint: &str) -> Option<String> {
|
||||
@@ -201,6 +299,74 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn stubs(composer: Option<Value>, configured: &[&str]) -> Option<Vec<String>> {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
if let Some(composer) = composer {
|
||||
std::fs::write(tmp.path().join("composer.json"), composer.to_string()).unwrap();
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = configured.iter().map(|s| s.to_string()).collect();
|
||||
intelephense_stubs(tmp.path(), &s)
|
||||
}
|
||||
|
||||
fn added(stubs: Option<Vec<String>>) -> Vec<String> {
|
||||
let stubs = stubs.expect("stubs");
|
||||
assert_eq!(stubs[..DEFAULT_STUBS.len()], DEFAULT_STUBS.iter().map(|s| s.to_string()).collect::<Vec<_>>());
|
||||
stubs[DEFAULT_STUBS.len()..].to_vec()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn required_extensions_and_configured_stubs_follow_the_defaults() {
|
||||
let composer = json!({
|
||||
"require": {"php": "^8.1", "ext-redis": "*", "ext-json": "*", "monolog/monolog": "^3"},
|
||||
"require-dev": {"ext-Imagick": "*", "ext-ffi": "*", "ext-redis": "*"}
|
||||
});
|
||||
assert_eq!(
|
||||
added(stubs(Some(composer), &[" wordpress ", "Redis", ""])),
|
||||
["redis", "imagick", "FFI", "wordpress"]
|
||||
);
|
||||
assert_eq!(added(stubs(None, &["wordpress"])), ["wordpress"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_beyond_the_defaults_sends_nothing() {
|
||||
assert_eq!(stubs(None, &[]), None);
|
||||
assert_eq!(stubs(Some(json!({"require": {"php": "^8.1"}})), &[]), None);
|
||||
// Default stub sets under their extension names: `ext-pdo`, `ext-zend-opcache`, `ext-simplexml`.
|
||||
let defaults =
|
||||
json!({"require": {"ext-pdo": "*", "ext-zend-opcache": "*", "ext-simplexml": "*", "ext-json": "*"}});
|
||||
assert_eq!(stubs(Some(defaults), &["SPL", "core"]), None);
|
||||
assert_eq!(stubs(Some(json!("not an object")), &[]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn include_paths_resolve_against_the_project() {
|
||||
let root = Path::new("/work/module");
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.include_paths = vec!["../platform".into(), "/opt/prestashop".into(), " ".into()];
|
||||
assert_eq!(
|
||||
include_paths(root, &s),
|
||||
[root.join("../platform").to_string_lossy().into_owned(), "/opt/prestashop".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_level_names_its_source() {
|
||||
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
|
||||
let require = r#"{"require": {"php": "^7.4"}}"#;
|
||||
let platform = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "8.2.1"}}}"#;
|
||||
|
||||
let require_level = found("7.4.0", PhpLevelSource::Require);
|
||||
assert_eq!(levels(Some(require), ""), (require_level.clone(), require_level.clone()));
|
||||
let platform_level = found("8.2.1", PhpLevelSource::Platform);
|
||||
assert_eq!(levels(Some(platform), ""), (platform_level.clone(), platform_level));
|
||||
// An explicit level: the composer one is still what Auto would pick.
|
||||
assert_eq!(levels(Some(require), "8.1"), (found("8.1.0", PhpLevelSource::Settings), require_level));
|
||||
assert_eq!(levels(None, "8.1"), (found("8.1.0", PhpLevelSource::Settings), None));
|
||||
assert_eq!(levels(None, ""), (None, None));
|
||||
assert_eq!(levels(Some(r#"{"require": {"php": "*"}}"#), ""), (None, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_level_wins() {
|
||||
let composer = r#"{"require": {"php": "^7.4"}, "config": {"platform": {"php": "7.4.33"}}}"#;
|
||||
|
||||
@@ -1325,6 +1325,30 @@ fn intelephense_gets_the_php_language_level() {
|
||||
assert_eq!(options(cfg), json!({"clearCache": true, "environment": {"phpVersion": "8.1.0"}}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intelephense_gets_the_required_stubs_and_include_paths() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("composer.json"), r#"{"require": {"php": "^8.1", "ext-redis": "*"}}"#).unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.php.stubs = vec!["wordpress".into()];
|
||||
s.languages.php.include_paths = vec!["../platform".into()];
|
||||
let mut cfg = server("intelephense");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
|
||||
let options = serde_json::to_value(cfg.initialization_options).unwrap();
|
||||
let stubs: Vec<&str> = options["stubs"].as_array().unwrap().iter().filter_map(|v| v.as_str()).collect();
|
||||
assert_eq!(stubs.len(), 70);
|
||||
assert_eq!((stubs[0], &stubs[68..]), ("apache", &["redis", "wordpress"][..]));
|
||||
assert_eq!(
|
||||
options["environment"],
|
||||
json!({"phpVersion": "8.1.0", "includePaths": [tmp.path().join("../platform").to_string_lossy()]})
|
||||
);
|
||||
|
||||
let mut other = server("phpactor");
|
||||
configure_server(&mut other, tmp.path(), tmp.path(), &s);
|
||||
assert_eq!(other.initialization_options, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_constraints_change_only_protocol_settings() {
|
||||
use serde_json::json;
|
||||
|
||||
@@ -267,19 +267,29 @@ impl Session {
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let failed = |code: Option<u32>| {
|
||||
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label))
|
||||
};
|
||||
// A command that exits early (no gzip, a folder it cannot write) stops reading: a
|
||||
// write would wait for channel window forever, so each one races the exit.
|
||||
let mut exit = std::pin::pin!(process.wait());
|
||||
let mut sent = 0;
|
||||
for chunk in packed.chunks(256 * 1024) {
|
||||
process.write(chunk).await?;
|
||||
tokio::select! {
|
||||
written = process.write(chunk) => written?,
|
||||
code = &mut exit => return Err(failed(code)),
|
||||
}
|
||||
sent += chunk.len();
|
||||
progress(sent as f64 / packed.len().max(1) as f64);
|
||||
}
|
||||
process.eof().await?;
|
||||
match process.wait().await {
|
||||
tokio::select! {
|
||||
closed = process.eof() => closed?,
|
||||
code = &mut exit => return Err(failed(code)),
|
||||
}
|
||||
match exit.await {
|
||||
Some(0) => Ok(()),
|
||||
code => {
|
||||
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label)))
|
||||
}
|
||||
code => Err(failed(code)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -269,3 +269,26 @@ async fn runs_commands_and_streams_processes() {
|
||||
assert_eq!(process.wait().await, Some(3));
|
||||
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_reports_a_command_that_stops_reading() {
|
||||
let env = env().await;
|
||||
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
|
||||
// Incompressible and larger than the channel window: the command exits (status 3, `oops`)
|
||||
// without reading it, as when the host has no gzip or the folder is not writable.
|
||||
let mut state = 0x9e37_79b9_7f4a_7c15_u64;
|
||||
let data: Vec<u8> = (0..8 << 20)
|
||||
.map(|_| {
|
||||
state ^= state << 13;
|
||||
state ^= state >> 7;
|
||||
state ^= state << 17;
|
||||
state as u8
|
||||
})
|
||||
.collect();
|
||||
let put = session.put("/home/dev/.openidea/server/openidea-server", &data);
|
||||
let err = tokio::time::timeout(std::time::Duration::from_secs(20), put)
|
||||
.await
|
||||
.expect("the upload must end when the command exits")
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("oops"), "{}", err.message);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
gix = { workspace = true, features = ["sha1", "sha256", "status", "blob-diff", "revision", "index", "dirwalk", "parallel"] }
|
||||
tracing.workspace = true
|
||||
redb.workspace = true
|
||||
blake3.workspace = true
|
||||
tempfile.workspace = true
|
||||
which = "8"
|
||||
regex-lite = "0.1"
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
//! Bek branch partitioning and bottom-up block merge.
|
||||
//! Adapted from JetBrains intellij-community BekBranchCreator/BekBranch/Merger,
|
||||
//! Copyright 2000-2026 JetBrains s.r.o. and contributors, Apache-2.0.
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::{BinaryHeap, HashSet};
|
||||
|
||||
pub(crate) fn sort(parents: &[Vec<usize>], times: &[i64], heads: &[usize]) -> Vec<usize> {
|
||||
let n = parents.len();
|
||||
let mut children = vec![Vec::new(); n];
|
||||
for (i, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
children[p].push(i);
|
||||
}
|
||||
}
|
||||
let mut layout = vec![0usize; n];
|
||||
let mut important = Vec::new();
|
||||
let mut next_layout = 1;
|
||||
for &head in heads {
|
||||
if layout[head] != 0 {
|
||||
continue;
|
||||
}
|
||||
important.push(head);
|
||||
let mut stack = vec![head];
|
||||
while let Some(&node) = stack.last() {
|
||||
let first = layout[node] == 0;
|
||||
if first {
|
||||
layout[node] = next_layout;
|
||||
}
|
||||
if let Some(&p) = parents[node].iter().find(|&&p| layout[p] == 0) {
|
||||
stack.push(p);
|
||||
} else {
|
||||
if first {
|
||||
next_layout += 1;
|
||||
}
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut done = vec![false; n];
|
||||
let mut restrictions = vec![HashSet::new(); n];
|
||||
let mut releases = vec![Vec::new(); n];
|
||||
let mut branches = Vec::new();
|
||||
for head in important {
|
||||
if done[head] {
|
||||
continue;
|
||||
}
|
||||
let start = layout[head];
|
||||
done[head] = true;
|
||||
let mut branch = vec![head];
|
||||
let mut stack = vec![head];
|
||||
while let Some(&node) = stack.last() {
|
||||
let mut next = None;
|
||||
for &p in parents[node].iter().rev() {
|
||||
if done[p] {
|
||||
if layout[p] < start && restrictions[node].insert(p) {
|
||||
releases[p].push(node);
|
||||
}
|
||||
} else if layout[node] <= layout[p] && !children[p].iter().any(|&c| !done[c] && layout[c] <= layout[p])
|
||||
{
|
||||
next = Some(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(p) = next {
|
||||
done[p] = true;
|
||||
branch.push(p);
|
||||
stack.push(p);
|
||||
} else {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
branches.push(branch);
|
||||
}
|
||||
let mut owner = vec![0usize; n];
|
||||
for (b, nodes) in branches.iter().enumerate() {
|
||||
for &node in nodes {
|
||||
owner[node] = b;
|
||||
}
|
||||
}
|
||||
let mut remaining: Vec<_> = branches.iter().map(Vec::len).collect();
|
||||
let mut prepared = vec![None; branches.len()];
|
||||
let mut queue = BinaryHeap::new();
|
||||
let prepare = |b: usize,
|
||||
remaining: &[usize],
|
||||
restrictions: &[HashSet<usize>],
|
||||
prepared: &mut [Option<usize>],
|
||||
queue: &mut BinaryHeap<Reverse<(i64, usize)>>| {
|
||||
let end = remaining[b];
|
||||
if end == 0 || prepared[b].is_some() || !restrictions[branches[b][end - 1]].is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut start = end - 1;
|
||||
while start > 0 {
|
||||
let up = branches[b][start - 1];
|
||||
let down = branches[b][start];
|
||||
if !restrictions[up].is_empty() {
|
||||
break;
|
||||
}
|
||||
let connects = parents[up].contains(&down);
|
||||
if !(parents[up].len() > 1 && connects) {
|
||||
if !connects {
|
||||
break;
|
||||
}
|
||||
let delta = times[up].abs_diff(times[down]);
|
||||
if delta > 3 * 24 * 3600 || (start + 20 < end && delta > 4 * 3600) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
start -= 1;
|
||||
}
|
||||
prepared[b] = Some(start);
|
||||
queue.push(Reverse((times[branches[b][start]], b)));
|
||||
};
|
||||
for b in 0..branches.len() {
|
||||
prepare(b, &remaining, &restrictions, &mut prepared, &mut queue);
|
||||
}
|
||||
let mut result = vec![0; n];
|
||||
let mut offset = n;
|
||||
while let Some(Reverse((_, b))) = queue.pop() {
|
||||
let start = prepared[b].take().unwrap();
|
||||
let part = &branches[b][start..remaining[b]];
|
||||
offset -= part.len();
|
||||
result[offset..offset + part.len()].copy_from_slice(part);
|
||||
remaining[b] = start;
|
||||
let mut released = HashSet::from([b]);
|
||||
for &node in part {
|
||||
for &up in &releases[node] {
|
||||
restrictions[up].remove(&node);
|
||||
let branch = owner[up];
|
||||
if restrictions[up].is_empty() && remaining[branch] > 0 && branches[branch][remaining[branch] - 1] == up
|
||||
{
|
||||
released.insert(branch);
|
||||
}
|
||||
}
|
||||
}
|
||||
for branch in released {
|
||||
prepare(branch, &remaining, &restrictions, &mut prepared, &mut queue);
|
||||
}
|
||||
}
|
||||
debug_assert_eq!(offset, 0, "Bek must place every node");
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn branch_blocks_preserve_every_edge_under_clock_skew_and_interleaved_refs() {
|
||||
let mut seed = 17u64;
|
||||
let mut random = || {
|
||||
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||
seed as usize
|
||||
};
|
||||
for _ in 0..200 {
|
||||
let n = 70;
|
||||
let parents: Vec<Vec<usize>> = (0..n)
|
||||
.map(|i| {
|
||||
let mut ps = Vec::new();
|
||||
if i + 1 < n {
|
||||
for _ in 0..(random() % 3 + 1) {
|
||||
let p = i + 1 + random() % (n - i - 1);
|
||||
if !ps.contains(&p) {
|
||||
ps.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
ps
|
||||
})
|
||||
.collect();
|
||||
let times: Vec<_> = (0..n).map(|_| (random() % 1_000_000) as i64).collect();
|
||||
let mut children = vec![0; n];
|
||||
for ps in &parents {
|
||||
for &p in ps {
|
||||
children[p] += 1;
|
||||
}
|
||||
}
|
||||
let mut heads: Vec<_> = (0..n).filter(|&i| children[i] == 0 || i % 11 == 0).collect();
|
||||
heads.sort_by_cached_key(|_| random());
|
||||
let result = sort(&parents, ×, &heads);
|
||||
assert_eq!(result.iter().copied().collect::<HashSet<_>>().len(), n);
|
||||
let mut positions = vec![0; n];
|
||||
for (i, &node) in result.iter().enumerate() {
|
||||
positions[node] = i;
|
||||
}
|
||||
for (node, ps) in parents.iter().enumerate() {
|
||||
for &p in ps {
|
||||
assert!(positions[node] < positions[p]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+735
-272
File diff suppressed because it is too large.
Load diff
+254
-3
@@ -112,22 +112,64 @@ pub struct Commit {
|
||||
pub id: String,
|
||||
pub short_id: String,
|
||||
pub parents: Vec<String>,
|
||||
/// Connections in the filtered graph; never use these as revision/diff parents.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub graph_parents: Option<Vec<String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub graph_dashed: Vec<bool>,
|
||||
/// Repository-relative filename at this revision in rename-following history.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub history_path: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_position: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub log_total: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub graph_boundary_parents: Vec<String>,
|
||||
/// Reachability from HEAD, computed independently of the visible filters.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub on_current_branch: Option<bool>,
|
||||
pub author: String,
|
||||
pub email: String,
|
||||
#[serde(default)]
|
||||
pub committer: String,
|
||||
#[serde(default)]
|
||||
pub committer_email: String,
|
||||
pub time_ms: f64,
|
||||
pub summary: String,
|
||||
pub message: String,
|
||||
#[serde(default)]
|
||||
pub message_truncated: bool,
|
||||
pub refs: Vec<String>,
|
||||
/// Committer time (Log › View Options › Commit Timestamp).
|
||||
#[serde(default)]
|
||||
pub commit_time_ms: f64,
|
||||
/// Per parent, under a user or text filter: the link passes commits the filter hides
|
||||
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
|
||||
/// Legacy alias of `graph_dashed`, indexed by displayed parents.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub dashed: Vec<bool>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogDetails {
|
||||
pub message: String,
|
||||
pub containing_branches: Vec<String>,
|
||||
/// Git's verification code: G/U good, B bad, E error, N unsigned, X/Y expired, R revoked.
|
||||
pub signature: String,
|
||||
pub signer: String,
|
||||
pub key: String,
|
||||
pub fingerprint: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogIndexProgress {
|
||||
pub indexed: u32,
|
||||
pub total: u32,
|
||||
pub running: bool,
|
||||
}
|
||||
|
||||
/// Order of the Git Log (Graph Options › Sort).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -137,13 +179,29 @@ pub enum LogOrder {
|
||||
Topo,
|
||||
/// `--date-order`: commit time, but never a parent before its children.
|
||||
Date,
|
||||
// Topological grouping with incoming merge parents visited before the main line.
|
||||
Standard,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub speed_search: Option<String>,
|
||||
#[serde(default)]
|
||||
pub search_fields: Vec<String>,
|
||||
/// Jump directly to an indexed window containing this commit (50 rows of context).
|
||||
#[serde(default)]
|
||||
pub seek: Option<String>,
|
||||
#[serde(default)]
|
||||
pub retain_until: Option<String>,
|
||||
pub branch: Option<String>,
|
||||
pub path: Option<ide_core::IpcPath>,
|
||||
#[serde(default)]
|
||||
pub paths: Vec<ide_core::IpcPath>,
|
||||
/// File history follows renames; structure filters retain fixed paths.
|
||||
#[serde(default)]
|
||||
pub follow: bool,
|
||||
pub author: Option<String>,
|
||||
pub text: Option<String>,
|
||||
pub skip: u32,
|
||||
@@ -152,6 +210,9 @@ pub struct LogQuery {
|
||||
/// not the left). Takes precedence over `branch`.
|
||||
#[serde(default)]
|
||||
pub range: Option<String>,
|
||||
/// Additional ranges unioned with each other and with selected branches.
|
||||
#[serde(default)]
|
||||
pub ranges: Vec<String>,
|
||||
/// `text` is a regular expression (Log filter `.*`).
|
||||
#[serde(default)]
|
||||
pub text_regex: bool,
|
||||
@@ -165,6 +226,8 @@ pub struct LogQuery {
|
||||
#[serde(default)]
|
||||
pub authors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub authors_exact: bool,
|
||||
#[serde(default)]
|
||||
pub order: LogOrder,
|
||||
/// Follow only the first parent of merges (`--first-parent`).
|
||||
#[serde(default)]
|
||||
@@ -179,6 +242,13 @@ pub struct LogQuery {
|
||||
pub since_ms: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub until_ms: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub request: Option<ide_core::RequestId>,
|
||||
#[serde(default)]
|
||||
pub summary_only: bool,
|
||||
/// Inclusive refresh boundary. Clients join only verified linear prefixes.
|
||||
#[serde(default)]
|
||||
pub stop_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
@@ -487,6 +557,7 @@ pub struct ChangelistState {
|
||||
pub files: std::collections::BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
mod bek;
|
||||
mod blame;
|
||||
mod block_history;
|
||||
pub mod changelists;
|
||||
@@ -496,6 +567,7 @@ mod diff;
|
||||
mod history;
|
||||
mod ignore;
|
||||
mod index_writes;
|
||||
mod log_index;
|
||||
mod manage;
|
||||
mod ops;
|
||||
mod patch;
|
||||
@@ -599,6 +671,9 @@ pub struct Repo {
|
||||
git_dir: PathBuf,
|
||||
inner: std::sync::Mutex<repo::Cache>,
|
||||
ignored: std::sync::RwLock<IgnoredNames>,
|
||||
log_index: std::sync::Mutex<log_index::LogIndex>,
|
||||
index_progress: std::sync::Mutex<LogIndexProgress>,
|
||||
log_signatures: std::sync::Mutex<std::collections::HashMap<String, LogDetails>>,
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
@@ -738,7 +813,183 @@ impl Repo {
|
||||
/// `refs` decorations are `HEAD`, branch names (`main`, `origin/main`) and `tag: <name>`.
|
||||
pub fn log(&self, query: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::log(self, query)
|
||||
history::log(self, query, &CancellationToken::new())
|
||||
}
|
||||
pub fn log_cancellable(&self, query: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
history::log(self, query, cancel)
|
||||
}
|
||||
/// Enables the disposable, content-addressed log cache outside the worktree.
|
||||
pub fn enable_log_index(&self, cache_dir: &Path) {
|
||||
let key = blake3::hash(self.git_dir.to_string_lossy().as_bytes()).to_hex().to_string();
|
||||
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).open(&cache_dir.join(key));
|
||||
}
|
||||
/// Reference names and peeled commit ids, including tags and detached HEAD.
|
||||
pub fn log_refs(&self) -> IdeResult<std::collections::BTreeMap<String, String>> {
|
||||
let repo = self.gix()?;
|
||||
Ok(history::decorations(&repo)?
|
||||
.into_iter()
|
||||
.flat_map(|(id, names)| names.into_iter().map(move |name| (name, id.to_string())))
|
||||
.collect())
|
||||
}
|
||||
pub fn log_users(&self) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let out = cli::run(self.root(), &["log", "--all", "--format=%an <%ae>"], None)?;
|
||||
Ok(out
|
||||
.stdout_str()
|
||||
.lines()
|
||||
.filter(|line| !line.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<std::collections::BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect())
|
||||
}
|
||||
pub fn log_index_progress(&self) -> LogIndexProgress {
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Incremental persistent inverted index; completed objects survive cancellation/restart.
|
||||
pub fn index_log(&self, cancel: &CancellationToken) -> IdeResult<LogIndexProgress> {
|
||||
{
|
||||
let mut progress = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
|
||||
if progress.running {
|
||||
return Ok(progress.clone());
|
||||
}
|
||||
progress.running = true;
|
||||
}
|
||||
let result = (|| {
|
||||
let repo = self.gix()?;
|
||||
let out = cli::run_cancellable(self.root(), &["rev-list", "--all", "--topo-order"], cancel)?;
|
||||
let ids: Vec<_> = out
|
||||
.stdout_str()
|
||||
.lines()
|
||||
.map(|id| gix::ObjectId::from_hex(id.as_bytes()).map_err(repo::gx))
|
||||
.collect::<IdeResult<_>>()?;
|
||||
{
|
||||
let mut p = self.index_progress.lock().unwrap_or_else(|p| p.into_inner());
|
||||
p.total = ids.len() as u32;
|
||||
p.indexed = 0;
|
||||
}
|
||||
for (i, id) in ids.iter().enumerate() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let indexed = self.log_index.lock().unwrap_or_else(|p| p.into_inner()).indexed(&id.to_string());
|
||||
if !indexed {
|
||||
let files = self.commit_files(&id.to_string())?;
|
||||
let paths =
|
||||
files.into_iter().flat_map(|f| std::iter::once(f.path).chain(f.old_path)).collect::<Vec<_>>();
|
||||
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let c = index.get(&repo, *id)?;
|
||||
let message = index.message(&repo, *id)?;
|
||||
index.index_commit(&c.id, &message, &c.author, &c.email, &paths);
|
||||
if i % 100 == 0 {
|
||||
index.flush();
|
||||
}
|
||||
}
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).indexed = i as u32 + 1;
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
self.log_index.lock().unwrap_or_else(|p| p.into_inner()).flush();
|
||||
self.index_progress.lock().unwrap_or_else(|p| p.into_inner()).running = false;
|
||||
result?;
|
||||
Ok(self.log_index_progress())
|
||||
}
|
||||
|
||||
pub fn log_details(&self, revision: &str) -> IdeResult<LogDetails> {
|
||||
self.log_details_cancellable(revision, &CancellationToken::new())
|
||||
}
|
||||
pub fn log_details_cancellable(&self, revision: &str, cancel: &CancellationToken) -> IdeResult<LogDetails> {
|
||||
let allowed = self.execution_allowed.load(std::sync::atomic::Ordering::Acquire);
|
||||
let _scope = crate::ExecutionScope::enter(allowed);
|
||||
let repo = self.gix()?;
|
||||
let object =
|
||||
repo.find_object(repo::resolve(&repo, revision)?).map_err(repo::gx)?.peel_to_commit().map_err(repo::gx)?;
|
||||
let commit_id = object.id;
|
||||
let id = commit_id.to_string();
|
||||
let contains = format!("--contains={id}");
|
||||
let branches =
|
||||
cli::run_cancellable(self.root(), &["branch", "-a", &contains, "--format=%(refname:short)"], cancel)?;
|
||||
let cache_key = format!("{allowed}:{id}");
|
||||
let cached = self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).get(&cache_key).cloned();
|
||||
let mut details = if let Some(details) = cached {
|
||||
details
|
||||
} else {
|
||||
let end = object.data.windows(2).position(|s| s == b"\n\n").unwrap_or(object.data.len());
|
||||
let headers = &object.data[..end];
|
||||
let signed = headers.windows(8).any(|s| s == b"\ngpgsig ") || headers.windows(8).any(|s| s == b"\ngpgsig-");
|
||||
let output = if allowed && signed {
|
||||
Some(cli::run_cancellable(
|
||||
self.root(),
|
||||
&["log", "-1", "--format=%G?%x00%GS%x00%GK%x00%GF", "--end-of-options", &id],
|
||||
cancel,
|
||||
)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let text = output.as_ref().map(|out| out.stdout_str()).unwrap_or_default();
|
||||
let fields: Vec<_> = text.trim_end().split('\0').collect();
|
||||
let message = {
|
||||
let mut index = self.log_index.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let message = index.message(&repo, commit_id)?;
|
||||
index.flush();
|
||||
message
|
||||
};
|
||||
let details = LogDetails {
|
||||
message,
|
||||
containing_branches: Vec::new(),
|
||||
signature: if !signed {
|
||||
"N".into()
|
||||
} else {
|
||||
fields.first().filter(|s| !s.is_empty()).unwrap_or(&"E").to_string()
|
||||
},
|
||||
signer: fields.get(1).unwrap_or(&"").to_string(),
|
||||
key: fields.get(2).unwrap_or(&"").to_string(),
|
||||
fingerprint: fields.get(3).unwrap_or(&"").to_string(),
|
||||
};
|
||||
self.log_signatures.lock().unwrap_or_else(|p| p.into_inner()).insert(cache_key, details.clone());
|
||||
details
|
||||
};
|
||||
details.containing_branches =
|
||||
branches.stdout_str().lines().filter(|s| !s.ends_with("/HEAD")).map(str::to_string).collect();
|
||||
Ok(details)
|
||||
}
|
||||
|
||||
/// Lists one immutable revision directory. Directory names end in `/`.
|
||||
pub fn revision_children(&self, revision: &str, directory: &str) -> IdeResult<Vec<String>> {
|
||||
let repo = self.gix()?;
|
||||
let id = repo::resolve(&repo, revision)?.to_string();
|
||||
let directory = self.rel_path(std::path::Path::new(directory))?;
|
||||
let tree = if directory.is_empty() { id } else { format!("{id}:{directory}") };
|
||||
let out = cli::run(self.root(), &["ls-tree", "-z", "--", &tree], None)?;
|
||||
Ok(out
|
||||
.stdout
|
||||
.split(|b| *b == 0)
|
||||
.filter_map(|line| {
|
||||
let split = line.iter().position(|b| *b == b'\t')?;
|
||||
let name = String::from_utf8_lossy(&line[split + 1..]);
|
||||
let dir = line.starts_with(b"040000 ");
|
||||
Some(format!(
|
||||
"{}{name}{}",
|
||||
if directory.is_empty() { String::new() } else { format!("{directory}/") },
|
||||
if dir { "/" } else { "" }
|
||||
))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn revision_files(&self, revision: &str) -> IdeResult<Vec<String>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
let repo = self.gix()?;
|
||||
let id = repo::resolve(&repo, revision)?.to_string();
|
||||
let out = cli::run(self.root(), &["ls-tree", "-r", "-z", "--name-only", "--", &id], None)?;
|
||||
Ok(out
|
||||
.stdout
|
||||
.split(|b| *b == 0)
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| String::from_utf8_lossy(p).into_owned())
|
||||
.collect())
|
||||
}
|
||||
/// Files changed by `commit` relative to its first parent (renames detected).
|
||||
pub fn commit_files(&self, commit: &str) -> IdeResult<Vec<ChangedFile>> {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Immutable commit metadata, persisted by object id. Ref decorations and graph
|
||||
//! projections are deliberately never cached as commit metadata.
|
||||
|
||||
use crate::Commit;
|
||||
use crate::repo::{gx, to_string};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::IdeResult;
|
||||
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
use std::time::SystemTime;
|
||||
|
||||
const COMMITS: TableDefinition<&str, &[u8]> = TableDefinition::new("summaries-v2");
|
||||
const MESSAGES: TableDefinition<&str, &str> = TableDefinition::new("messages-v1");
|
||||
const PATHS: TableDefinition<&str, &[u8]> = TableDefinition::new("paths-v2");
|
||||
const POSTINGS: MultimapTableDefinition<&str, &str> = MultimapTableDefinition::new("search-v1");
|
||||
const INDEXED: TableDefinition<&str, bool> = TableDefinition::new("indexed-v1");
|
||||
|
||||
pub(crate) struct IndexedView {
|
||||
pub commits: Vec<Commit>,
|
||||
pub positions: HashMap<String, usize>,
|
||||
}
|
||||
impl IndexedView {
|
||||
pub fn new(commits: Vec<Commit>) -> Self {
|
||||
let positions = commits.iter().enumerate().map(|(i, c)| (c.id.clone(), i)).collect();
|
||||
Self { commits, positions }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct LogIndex {
|
||||
opened: bool,
|
||||
db: Option<Arc<Database>>,
|
||||
memory: HashMap<ObjectId, Arc<Commit>>,
|
||||
dirty: Vec<Arc<Commit>>,
|
||||
dirty_messages: Vec<(String, String)>,
|
||||
dirty_search: Vec<(String, Vec<String>)>,
|
||||
path_memory: HashMap<(ObjectId, String), Option<(ObjectId, u16)>>,
|
||||
dirty_paths: Vec<(String, Option<(String, u16)>)>,
|
||||
pub head: Option<(ObjectId, Option<SystemTime>)>,
|
||||
pub reachable: HashSet<ObjectId>,
|
||||
pub views: HashMap<String, Arc<IndexedView>>,
|
||||
pub nodes: HashMap<ObjectId, (Vec<ObjectId>, i64)>,
|
||||
pub shallow: HashSet<ObjectId>,
|
||||
}
|
||||
|
||||
impl LogIndex {
|
||||
/// A permanent DAG: refresh discovers only new objects, then joins arbitrary
|
||||
/// changed tips against known ancestry. Reachability is recomputed for resets/deleted refs.
|
||||
pub fn graph(
|
||||
&mut self,
|
||||
repo: &gix::Repository,
|
||||
tips: &[ObjectId],
|
||||
shallow: &HashSet<ObjectId>,
|
||||
first_parent: bool,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<ObjectId>> {
|
||||
if &self.shallow != shallow {
|
||||
self.nodes.clear();
|
||||
self.shallow = shallow.clone();
|
||||
}
|
||||
let mut pending = tips.to_vec();
|
||||
while let Some(id) = pending.pop() {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
if self.nodes.contains_key(&id) {
|
||||
continue;
|
||||
}
|
||||
let record = self.get(repo, id)?;
|
||||
let parents: Vec<_> = if shallow.contains(&id) {
|
||||
Vec::new()
|
||||
} else {
|
||||
record.parents.iter().map(|s| ObjectId::from_hex(s.as_bytes()).map_err(gx)).collect::<IdeResult<_>>()?
|
||||
};
|
||||
pending.extend(parents.iter().copied());
|
||||
self.nodes.insert(id, (parents, (record.commit_time_ms / 1000.0) as i64));
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
let mut ids = Vec::new();
|
||||
let mut pending = tips.to_vec();
|
||||
while let Some(id) = pending.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
ids.push(id);
|
||||
if let Some((parents, _)) = self.nodes.get(&id) {
|
||||
pending.extend(parents.iter().take(if first_parent { 1 } else { usize::MAX }).rev().copied());
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
pub fn indexed(&self, id: &str) -> bool {
|
||||
self.db
|
||||
.as_ref()
|
||||
.and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(INDEXED).ok()?;
|
||||
Some(table.get(id).ok()??.value())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn index_commit(&mut self, id: &str, message: &str, author: &str, email: &str, paths: &[String]) {
|
||||
let mut keys: HashSet<String> = Self::grams(message).into_iter().map(|s| format!("m:{s}")).collect();
|
||||
for user in [author, email, email.split('@').next().unwrap_or(email)] {
|
||||
keys.insert(format!("u:{}", normalize_user(user)));
|
||||
}
|
||||
for path in paths {
|
||||
keys.insert(format!("p:{path}"));
|
||||
}
|
||||
self.dirty_search.push((id.into(), keys.into_iter().collect()));
|
||||
}
|
||||
|
||||
fn grams(text: &str) -> Vec<String> {
|
||||
let chars: Vec<_> = text.to_lowercase().chars().collect();
|
||||
chars.windows(3).map(|s| s.iter().collect()).collect::<HashSet<_>>().into_iter().collect()
|
||||
}
|
||||
|
||||
/// Missing/unindexed commits are always checked directly by the caller.
|
||||
pub fn message_candidates(&self, text: &str) -> Option<HashSet<String>> {
|
||||
let grams = Self::grams(text);
|
||||
if grams.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let db = self.db.as_ref()?;
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut candidates: Option<HashSet<String>> = None;
|
||||
for gram in grams {
|
||||
let values = table.get(format!("m:{gram}").as_str()).ok()?;
|
||||
let ids: HashSet<_> = values.filter_map(Result::ok).map(|s| s.value().to_string()).collect();
|
||||
candidates = Some(match candidates {
|
||||
Some(old) => old.intersection(&ids).cloned().collect(),
|
||||
None => ids,
|
||||
});
|
||||
if candidates.as_ref().is_some_and(HashSet::is_empty) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(candidates.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub fn user_candidates(&self, authors: &[String]) -> Option<HashSet<String>> {
|
||||
let txn = self.db.as_ref()?.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut ids = HashSet::new();
|
||||
for author in authors {
|
||||
let name = author.split('<').next().unwrap_or(author).trim();
|
||||
let email = author.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(author);
|
||||
for alias in [name, email, email.split('@').next().unwrap_or(email)] {
|
||||
for value in table.get(format!("u:{}", normalize_user(alias)).as_str()).ok()?.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
|
||||
pub fn path_candidates(&self, paths: &[String]) -> Option<HashSet<String>> {
|
||||
let txn = self.db.as_ref()?.begin_read().ok()?;
|
||||
let table = txn.open_multimap_table(POSTINGS).ok()?;
|
||||
let mut ids = HashSet::new();
|
||||
for path in paths {
|
||||
for value in table.get(format!("p:{path}").as_str()).ok()?.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
let prefix = format!("p:{path}/");
|
||||
let end = format!("{prefix}{}", char::MAX);
|
||||
for row in table.range(prefix.as_str()..end.as_str()).ok()?.filter_map(Result::ok) {
|
||||
for value in row.1.filter_map(Result::ok) {
|
||||
ids.insert(value.value().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(ids)
|
||||
}
|
||||
pub fn open(&mut self, dir: &Path) {
|
||||
if self.opened {
|
||||
return;
|
||||
}
|
||||
self.opened = true;
|
||||
// Several windows can read the same worktree; redb permits one owning
|
||||
// handle per file, shared here rather than silently disabling its index.
|
||||
static DATABASES: OnceLock<Mutex<HashMap<std::path::PathBuf, Weak<Database>>>> = OnceLock::new();
|
||||
let path = dir.join("commits.redb");
|
||||
let mut databases = DATABASES.get_or_init(Mutex::default).lock().unwrap_or_else(|p| p.into_inner());
|
||||
self.db = databases.get(&path).and_then(Weak::upgrade).or_else(|| {
|
||||
let db = Arc::new(std::fs::create_dir_all(dir).ok().and_then(|_| Database::create(&path).ok())?);
|
||||
databases.insert(path, Arc::downgrade(&db));
|
||||
Some(db)
|
||||
});
|
||||
}
|
||||
|
||||
pub fn get(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<Arc<Commit>> {
|
||||
if let Some(commit) = self.memory.get(&id) {
|
||||
return Ok(commit.clone());
|
||||
}
|
||||
let key = id.to_string();
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(COMMITS).ok()?;
|
||||
let value = table.get(key.as_str()).ok()??;
|
||||
serde_json::from_slice::<Commit>(value.value()).ok().filter(|c| c.id == key)
|
||||
});
|
||||
let commit = if let Some(commit) = cached {
|
||||
Arc::new(commit)
|
||||
} else {
|
||||
let commit = repo.find_commit(id).map_err(gx)?;
|
||||
let author = commit.author().map_err(gx)?;
|
||||
let committer = commit.committer().map_err(gx)?;
|
||||
let raw = commit.message_raw_sloppy();
|
||||
let summary = raw.split(|b| *b == b'\n').find(|l| !l.trim().is_empty()).unwrap_or_default().trim();
|
||||
let summary_text = to_string(summary.as_bstr());
|
||||
let record = Arc::new(Commit {
|
||||
id: key.clone(),
|
||||
short_id: key[..key.len().min(8)].to_string(),
|
||||
parents: commit.parent_ids().map(|p| p.to_string()).collect(),
|
||||
graph_parents: None,
|
||||
graph_dashed: Vec::new(),
|
||||
dashed: Vec::new(),
|
||||
on_current_branch: None,
|
||||
author: to_string(author.name.trim().as_bstr()),
|
||||
email: to_string(author.email.trim().as_bstr()),
|
||||
committer: to_string(committer.name.trim().as_bstr()),
|
||||
committer_email: to_string(committer.email.trim().as_bstr()),
|
||||
time_ms: author.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
|
||||
commit_time_ms: committer.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0),
|
||||
summary: summary_text.clone(),
|
||||
message: summary_text,
|
||||
message_truncated: raw.trim() != summary,
|
||||
history_path: None,
|
||||
log_position: None,
|
||||
log_total: None,
|
||||
graph_boundary_parents: Vec::new(),
|
||||
refs: Vec::new(),
|
||||
});
|
||||
if self.db.is_some() {
|
||||
self.dirty.push(record.clone());
|
||||
}
|
||||
record
|
||||
};
|
||||
// Keep hot pages in memory; older metadata stays in the persistent index.
|
||||
if self.memory.len() >= 4096 {
|
||||
self.flush();
|
||||
self.memory.clear();
|
||||
}
|
||||
self.memory.insert(id, commit.clone());
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
pub fn message(&mut self, repo: &gix::Repository, id: ObjectId) -> IdeResult<String> {
|
||||
if let Some((_, message)) = self.dirty_messages.iter().find(|(key, _)| key == &id.to_string()) {
|
||||
return Ok(message.clone());
|
||||
}
|
||||
let key = id.to_string();
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(MESSAGES).ok()?;
|
||||
Some(table.get(key.as_str()).ok()??.value().to_string())
|
||||
});
|
||||
if let Some(message) = cached {
|
||||
return Ok(message);
|
||||
}
|
||||
let message = to_string(repo.find_commit(id).map_err(gx)?.message_raw_sloppy()).trim_end().to_string();
|
||||
if self.db.is_some() {
|
||||
self.dirty_messages.push((key, message.clone()));
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
if self.dirty.is_empty()
|
||||
&& self.dirty_paths.is_empty()
|
||||
&& self.dirty_messages.is_empty()
|
||||
&& self.dirty_search.is_empty()
|
||||
{
|
||||
return;
|
||||
}
|
||||
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
let Some(db) = &self.db else {
|
||||
return Ok(());
|
||||
};
|
||||
let txn = db.begin_write()?;
|
||||
{
|
||||
let mut table = txn.open_table(COMMITS)?;
|
||||
for commit in &self.dirty {
|
||||
let bytes = serde_json::to_vec(commit.as_ref())?;
|
||||
table.insert(commit.id.as_str(), bytes.as_slice())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(PATHS)?;
|
||||
for (key, value) in &self.dirty_paths {
|
||||
let bytes = serde_json::to_vec(value)?;
|
||||
table.insert(key.as_str(), bytes.as_slice())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut table = txn.open_table(MESSAGES)?;
|
||||
for (id, message) in &self.dirty_messages {
|
||||
table.insert(id.as_str(), message.as_str())?;
|
||||
}
|
||||
}
|
||||
{
|
||||
let mut postings = txn.open_multimap_table(POSTINGS)?;
|
||||
let mut indexed = txn.open_table(INDEXED)?;
|
||||
for (id, keys) in &self.dirty_search {
|
||||
for key in keys {
|
||||
postings.insert(key.as_str(), id.as_str())?;
|
||||
}
|
||||
indexed.insert(id.as_str(), true)?;
|
||||
}
|
||||
}
|
||||
txn.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = result {
|
||||
tracing::warn!("Git log cache write failed: {error}");
|
||||
}
|
||||
self.dirty_messages.clear();
|
||||
self.dirty_search.clear();
|
||||
self.dirty.clear();
|
||||
self.dirty_paths.clear();
|
||||
}
|
||||
|
||||
pub fn path_entry(
|
||||
&mut self,
|
||||
repo: &gix::Repository,
|
||||
id: ObjectId,
|
||||
path: &str,
|
||||
) -> IdeResult<Option<(ObjectId, u16)>> {
|
||||
let memory_key = (id, path.to_string());
|
||||
if let Some(value) = self.path_memory.get(&memory_key) {
|
||||
return Ok(*value);
|
||||
}
|
||||
let key = format!("{id}\0{path}");
|
||||
let cached = self.db.as_ref().and_then(|db| {
|
||||
let txn = db.begin_read().ok()?;
|
||||
let table = txn.open_table(PATHS).ok()?;
|
||||
let value = table.get(key.as_str()).ok()??;
|
||||
serde_json::from_slice::<Option<(String, u16)>>(value.value()).ok()
|
||||
});
|
||||
let cached = match cached {
|
||||
Some(None) => Some(None),
|
||||
Some(Some((hex, mode))) => ObjectId::from_hex(hex.as_bytes()).ok().map(|id| Some((id, mode))),
|
||||
None => None,
|
||||
};
|
||||
let value = if let Some(value) = cached {
|
||||
value
|
||||
} else {
|
||||
let tree = repo.find_commit(id).map_err(gx)?.tree().map_err(gx)?;
|
||||
let value = tree
|
||||
.lookup_entry_by_path(Path::new(path))
|
||||
.map_err(gx)?
|
||||
.map(|entry| (entry.object_id(), entry.mode().value()));
|
||||
if self.db.is_some() {
|
||||
self.dirty_paths.push((key, value.map(|(id, mode)| (id.to_string(), mode))));
|
||||
}
|
||||
value
|
||||
};
|
||||
if self.path_memory.len() >= 4096 {
|
||||
self.flush();
|
||||
self.path_memory.clear();
|
||||
}
|
||||
self.path_memory.insert(memory_key, value);
|
||||
Ok(value)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_user(user: &str) -> String {
|
||||
user.chars().filter(|c| c.is_alphanumeric()).flat_map(char::to_lowercase).collect()
|
||||
}
|
||||
|
||||
pub(crate) fn matches_user(query: &str, name: &str, email: &str) -> bool {
|
||||
let query_name = query.split('<').next().unwrap_or(query).trim();
|
||||
let query_email = query.split('<').nth(1).map(|s| s.trim_end_matches('>')).unwrap_or(query);
|
||||
let target =
|
||||
[normalize_user(name), normalize_user(email), normalize_user(email.split('@').next().unwrap_or(email))];
|
||||
[query_name, query_email, query_email.split('@').next().unwrap_or(query_email)]
|
||||
.iter()
|
||||
.any(|alias| target.contains(&normalize_user(alias)))
|
||||
}
|
||||
@@ -64,6 +64,9 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
git_dir,
|
||||
inner: std::sync::Mutex::new(cache),
|
||||
ignored: Default::default(),
|
||||
log_index: Default::default(),
|
||||
index_progress: Default::default(),
|
||||
log_signatures: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
use ide_vcs::{LogOrder, LogQuery, Repo};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) -> String {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args([
|
||||
"-c",
|
||||
"init.defaultBranch=main",
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"-c",
|
||||
"commit.gpgsign=false",
|
||||
])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
|
||||
fn commit(dir: &Path, file: &str, message: &str) -> String {
|
||||
std::fs::write(dir.join(file), message).unwrap();
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["commit", "-qm", message]);
|
||||
git(dir, &["rev-parse", "HEAD"])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_and_independent_ranges_form_a_union() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
let first = commit(dir, "a", "first");
|
||||
let second = commit(dir, "a", "second");
|
||||
let third = commit(dir, "a", "third");
|
||||
git(dir, &["checkout", "-qb", "side", &root]);
|
||||
let side = commit(dir, "b", "side");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
|
||||
let log = repo
|
||||
.log(&LogQuery {
|
||||
branches: vec!["side".into()],
|
||||
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
let got: std::collections::HashSet<_> = log.iter().map(|c| c.id.clone()).collect();
|
||||
assert_eq!(got, [root.clone(), first.clone(), third.clone(), side.clone()].into());
|
||||
let ranges = repo
|
||||
.log(&LogQuery {
|
||||
ranges: vec![format!("{root}..{first}"), format!("{second}..{third}")],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ranges.iter().map(|c| c.id.clone()).collect::<std::collections::HashSet<_>>(),
|
||||
[first.clone(), third.clone()].into()
|
||||
);
|
||||
let positions: std::collections::HashMap<_, _> =
|
||||
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
|
||||
for (i, c) in log.iter().enumerate() {
|
||||
for p in c.graph_parents.as_ref().unwrap() {
|
||||
assert!(positions.get(p.as_str()).is_none_or(|p| *p > i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let mut ids = Vec::new();
|
||||
for i in 0..70 {
|
||||
ids.push(commit(dir, "a", &format!("summary {i}\n\nneedle body {i}")));
|
||||
}
|
||||
let cache = tempfile::tempdir().unwrap();
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
repo.enable_log_index(cache.path());
|
||||
let query = LogQuery { summary_only: true, limit: 5, ..Default::default() };
|
||||
let page = repo.log(&query).unwrap();
|
||||
assert_eq!(page[0].message, "summary 69");
|
||||
assert!(page[0].message_truncated);
|
||||
assert_eq!(page[0].log_position, Some(0));
|
||||
assert_eq!(page[0].log_total, Some(70));
|
||||
let window = repo.log(&LogQuery { seek: Some(ids[5].clone()), limit: 20, ..query.clone() }).unwrap();
|
||||
assert!(window.iter().any(|c| c.id == ids[5]));
|
||||
assert!(window[0].log_position.unwrap() > 0);
|
||||
assert!(repo.log(&LogQuery { request: Some(ide_core::RequestId(1)), ..query.clone() }).is_ok());
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert!(repo.log_cancellable(&query, &cancel).is_err());
|
||||
let progress = repo.index_log(&ide_core::CancellationToken::new()).unwrap();
|
||||
assert_eq!((progress.indexed, progress.total, progress.running), (70, 70, false));
|
||||
assert_eq!(repo.log(&LogQuery { text: Some("needle body 5".into()), ..query.clone() }).unwrap().len(), 5);
|
||||
let new = commit(dir, "b", "new head");
|
||||
let refreshed = repo.log(&LogQuery { retain_until: Some(ids[0].clone()), ..query.clone() }).unwrap();
|
||||
assert_eq!(refreshed[0].id, new);
|
||||
assert_eq!(refreshed.len(), 71);
|
||||
drop(repo);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
repo.enable_log_index(cache.path());
|
||||
let hit = repo.log(&LogQuery { text: Some("needle body 69".into()), ..query }).unwrap();
|
||||
assert_eq!(hit[0].id, ids[69]);
|
||||
assert_eq!(repo.log_details(&ids[69]).unwrap().message, "summary 69\n\nneedle body 69");
|
||||
assert_eq!(repo.log_details(&ids[69]).unwrap().signature, "N");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn followed_history_carries_the_filename_at_each_revision() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let first = commit(dir, "old.txt", "original");
|
||||
git(dir, &["mv", "old.txt", "new.txt"]);
|
||||
git(dir, &["commit", "-qm", "rename"]);
|
||||
let renamed = git(dir, &["rev-parse", "HEAD"]);
|
||||
let tip = commit(dir, "new.txt", "latest");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(
|
||||
log.iter().map(|c| (c.id.as_str(), c.history_path.as_deref())).collect::<Vec<_>>(),
|
||||
vec![(tip.as_str(), Some("new.txt")), (renamed.as_str(), Some("new.txt")), (first.as_str(), Some("old.txt"))]
|
||||
);
|
||||
assert_eq!(repo.revision_children(&first, "").unwrap(), vec!["old.txt"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detail_filters_connect_visible_ancestors_without_changing_revision_parents() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let a = commit(dir, "a", "keep root");
|
||||
commit(dir, "b", "hidden middle");
|
||||
git(dir, &["commit", "--amend", "--no-edit", "--author=Other <[email protected]>"]);
|
||||
let b = git(dir, &["rev-parse", "HEAD"]);
|
||||
let c = commit(dir, "a", "keep tip");
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for q in [
|
||||
LogQuery { text: Some("keep".into()), ..Default::default() },
|
||||
LogQuery { path: Some("a".into()), ..Default::default() },
|
||||
LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() },
|
||||
] {
|
||||
let page = repo.log(&LogQuery { limit: 1, ..q.clone() }).unwrap();
|
||||
assert_eq!(page[0].id, c);
|
||||
assert_eq!(page[0].parents, vec![b.clone()]);
|
||||
assert_eq!(page[0].graph_parents, Some(vec![a.clone()]));
|
||||
assert_eq!(page[0].graph_dashed, vec![true]);
|
||||
assert_eq!(page[0].on_current_branch, Some(true));
|
||||
let next = repo.log(&LogQuery { skip: 1, limit: 1, ..q }).unwrap();
|
||||
assert_eq!(next[0].id, a);
|
||||
assert_eq!(next[0].graph_parents, Some(vec![]));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_history_includes_commits_reachable_only_from_tags() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
let tip = commit(dir, "a", "tagged tip");
|
||||
git(dir, &["tag", "v1"]);
|
||||
git(dir, &["reset", "--hard", &root]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery::default()).unwrap();
|
||||
let tagged = log.iter().find(|c| c.id == tip).unwrap();
|
||||
assert!(tagged.refs.contains(&"tag: v1".into()));
|
||||
assert_eq!(tagged.on_current_branch, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projection_does_not_truncate_long_hidden_chains() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let mut stream = String::new();
|
||||
for i in 0..4100 {
|
||||
let message = if i == 0 || i == 4099 { "keep" } else { "hide" };
|
||||
stream.push_str(&format!(
|
||||
"commit refs/heads/main\ncommitter Test <[email protected]> {} +0000\ndata {}\n{}\n\n",
|
||||
1000 + i,
|
||||
message.len(),
|
||||
message
|
||||
));
|
||||
}
|
||||
let mut child = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["fast-import", "--quiet"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
child.stdin.take().unwrap().write_all(stream.as_bytes()).unwrap();
|
||||
assert!(child.wait().unwrap().success());
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { text: Some("keep".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 2);
|
||||
assert_eq!(log[0].graph_parents, Some(vec![log[1].id.clone()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_and_shallow_history_remain_topological_with_clock_skew() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let source = tmp.path().join("source");
|
||||
std::fs::create_dir(&source).unwrap();
|
||||
git(&source, &["init", "-q"]);
|
||||
let make = |message: &str, parent: Option<&str>, time: &str| {
|
||||
std::fs::write(source.join("a.txt"), message).unwrap();
|
||||
git(&source, &["add", "a.txt"]);
|
||||
let tree = git(&source, &["write-tree"]);
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.current_dir(&source).args([
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"[email protected]",
|
||||
"commit-tree",
|
||||
&tree,
|
||||
"-m",
|
||||
message,
|
||||
]);
|
||||
if let Some(p) = parent {
|
||||
cmd.args(["-p", p]);
|
||||
}
|
||||
let out = cmd.env("GIT_AUTHOR_DATE", time).env("GIT_COMMITTER_DATE", time).output().unwrap();
|
||||
assert!(out.status.success());
|
||||
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||
};
|
||||
let root = make("root", None, "@5000 +0000");
|
||||
let left = make("left", Some(&root), "@1000 +0000");
|
||||
let right = make("right", Some(&root), "@2000 +0000");
|
||||
git(&source, &["update-ref", "refs/heads/main", &left]);
|
||||
git(&source, &["update-ref", "refs/heads/side", &right]);
|
||||
let shallow = tmp.path().join("shallow");
|
||||
git(
|
||||
tmp.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
"--depth=2",
|
||||
"--no-single-branch",
|
||||
&format!("file://{}", source.display()),
|
||||
shallow.to_str().unwrap(),
|
||||
],
|
||||
);
|
||||
for dir in [&source, &shallow] {
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
|
||||
let log = repo.log(&LogQuery { order, path: Some("a.txt".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 3);
|
||||
let positions: std::collections::HashMap<_, _> =
|
||||
log.iter().enumerate().map(|(i, c)| (c.id.as_str(), i)).collect();
|
||||
for (i, c) in log.iter().enumerate() {
|
||||
for parent in c.graph_parents.as_ref().unwrap() {
|
||||
assert!(positions.get(parent.as_str()).is_none_or(|p| *p > i));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_paths_exact_authors_patterns_and_rename_history() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "old.txt", "root");
|
||||
git(dir, &["mv", "old.txt", "new.txt"]);
|
||||
git(dir, &["commit", "-qm", "rename"]);
|
||||
let rename = git(dir, &["rev-parse", "HEAD"]);
|
||||
let tip = commit(dir, "other.txt", "another file");
|
||||
git(dir, &["branch", "feature/log"]);
|
||||
git(dir, &["branch", "feature/skip", &root]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { paths: vec!["new.txt".into(), "other.txt".into()], ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![tip.clone(), rename.clone()]);
|
||||
let log = repo.log(&LogQuery { path: Some("new.txt".into()), follow: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), vec![rename, root.clone()]);
|
||||
let log = repo
|
||||
.log(&LogQuery { branches: vec!["feature/.*".into(), "-feature/skip".into()], ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(log[0].id, tip);
|
||||
std::fs::write(dir.join("other.txt"), "different author").unwrap();
|
||||
git(dir, &["add", "-A"]);
|
||||
git(dir, &["-c", "user.name=Test User", "-c", "[email protected]", "commit", "-qm", "different author"]);
|
||||
let log = repo.log(&LogQuery { authors: vec!["Test".into()], authors_exact: true, ..Default::default() }).unwrap();
|
||||
assert_eq!(log.len(), 3);
|
||||
assert!(log.iter().all(|c| c.author == "Test"));
|
||||
assert!(repo.log_users().unwrap().contains(&"Test User <[email protected]>".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metadata_and_path_cache_survive_restart_without_caching_refs_or_filters() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path().join("repo");
|
||||
std::fs::create_dir(&dir).unwrap();
|
||||
git(&dir, &["init", "-q"]);
|
||||
let first = commit(&dir, "a", "first\n\nComplete commit body");
|
||||
git(&dir, &["tag", "v1"]);
|
||||
git(&dir, &["tag", "-a", "annotated", "-m", "release"]);
|
||||
let cache = tmp.path().join("cache");
|
||||
{
|
||||
let repo = Repo::discover(&dir).unwrap().unwrap();
|
||||
repo.enable_log_index(&cache);
|
||||
let log = repo.log(&LogQuery { path: Some("a".into()), summary_only: true, ..Default::default() }).unwrap();
|
||||
assert!(log[0].message_truncated);
|
||||
assert_eq!(log[0].message, "first");
|
||||
assert_eq!(repo.log_details(&first).unwrap().message, "first\n\nComplete commit body");
|
||||
let details = repo.log_details("annotated").unwrap();
|
||||
assert_eq!(details.message, "first\n\nComplete commit body");
|
||||
assert_eq!(details.signature, "N");
|
||||
assert!(details.containing_branches.contains(&"main".to_string()));
|
||||
assert_eq!(repo.revision_files(&first).unwrap(), vec!["a"]);
|
||||
}
|
||||
let second = commit(&dir, "a", "second");
|
||||
git(&dir, &["tag", "-f", "v1"]);
|
||||
let repo = Repo::discover(&dir).unwrap().unwrap();
|
||||
repo.enable_log_index(&cache);
|
||||
let log = repo.log(&LogQuery { path: Some("a".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log[0].id, second);
|
||||
assert!(log[0].refs.contains(&"tag: v1".into()));
|
||||
assert!(!log[1].refs.contains(&"tag: v1".into()));
|
||||
assert!(log[1].message.contains("Complete commit body"));
|
||||
assert!(!log[1].message_truncated);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_query_does_not_publish_a_partial_page() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
git(tmp.path(), &["init", "-q"]);
|
||||
commit(tmp.path(), "a", "root");
|
||||
let repo = Repo::discover(tmp.path()).unwrap().unwrap();
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(
|
||||
repo.log_cancellable(&LogQuery::default(), &cancel).unwrap_err().kind,
|
||||
ide_core::error::ErrorKind::Cancelled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shallow_boundaries_do_not_reconnect_to_locally_present_ancestors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "keep root");
|
||||
let boundary = commit(dir, "a", "hidden boundary");
|
||||
let tip = commit(dir, "a", "keep tip");
|
||||
git(dir, &["tag", "root", &root]);
|
||||
std::fs::write(dir.join(".git/shallow"), format!("{boundary}\n")).unwrap();
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
for order in [LogOrder::Date, LogOrder::Topo, LogOrder::Standard] {
|
||||
for text in [None, Some("keep".to_string())] {
|
||||
let log = repo.log(&LogQuery { order, text, path: Some("a".into()), ..Default::default() }).unwrap();
|
||||
let boundary_row = log.iter().find(|c| c.id == boundary);
|
||||
if let Some(row) = boundary_row {
|
||||
assert_eq!(row.parents, vec![root.clone()]);
|
||||
assert_eq!(row.graph_parents, Some(vec![]));
|
||||
} else {
|
||||
assert_eq!(log.iter().find(|c| c.id == tip).unwrap().graph_parents, Some(vec![]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_branch_exclusions_treat_punctuation_literally() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "a", "root");
|
||||
git(dir, &["branch", "release/1.0"]);
|
||||
let tip = commit(dir, "a", "tip");
|
||||
git(dir, &["branch", "release/1x0"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo
|
||||
.log(&LogQuery { branches: vec!["release/.*".into(), "-release/1.0".into()], ..Default::default() })
|
||||
.unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mode_only_changes_appear_in_path_history() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
let root = commit(dir, "script", "root");
|
||||
git(dir, &["update-index", "--chmod=+x", "script"]);
|
||||
git(dir, &["commit", "-qm", "make executable"]);
|
||||
let tip = git(dir, &["rev-parse", "HEAD"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let log = repo.log(&LogQuery { path: Some("script".into()), ..Default::default() }).unwrap();
|
||||
assert_eq!(log.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), vec![tip.as_str(), root.as_str()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_paths_are_simplified_together_at_merges() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"]);
|
||||
commit(dir, "a", "a0");
|
||||
commit(dir, "b", "b0");
|
||||
git(dir, &["branch", "side"]);
|
||||
commit(dir, "a", "left");
|
||||
git(dir, &["checkout", "side"]);
|
||||
commit(dir, "b", "right");
|
||||
git(dir, &["checkout", "main"]);
|
||||
git(dir, &["merge", "--no-ff", "side", "-m", "merge both paths"]);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
let expected = git(dir, &["log", "--topo-order", "--format=%H", "--", "a", "b"]);
|
||||
for order in [LogOrder::Topo, LogOrder::Date, LogOrder::Standard] {
|
||||
let log = repo
|
||||
.log(&LogQuery {
|
||||
branch: Some("HEAD".into()),
|
||||
paths: vec!["a".into(), "b".into()],
|
||||
order,
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(log[0].summary, "merge both paths");
|
||||
assert_eq!(
|
||||
log.iter().map(|c| c.id.as_str()).collect::<std::collections::HashSet<_>>(),
|
||||
expected.lines().collect()
|
||||
);
|
||||
assert_eq!(log[0].parents.len(), 2);
|
||||
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,7 @@ fn ids(commits: &[Commit]) -> Vec<String> {
|
||||
}
|
||||
|
||||
fn parents_of(commits: &[Commit], id: &str) -> Vec<String> {
|
||||
commits.iter().find(|c| c.id == id).unwrap().parents.clone()
|
||||
commits.iter().find(|c| c.id == id).unwrap().graph_parents.clone().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -75,6 +75,14 @@ fn date_order_follows_commit_time_and_reports_it() {
|
||||
assert_eq!(out[0].commit_time_ms, 5_000_000.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incoming_order_groups_the_merged_branch_before_the_main_parent() {
|
||||
let h = history();
|
||||
let out = log(&h, LogQuery { order: LogOrder::Standard, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.s1.clone(), h.c2.clone(), h.c1.clone()]);
|
||||
assert_eq!(out[1].parents, vec![h.c2.clone(), h.s1.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_merges_links_children_to_the_merge_parents() {
|
||||
let h = history();
|
||||
@@ -90,6 +98,7 @@ fn first_parent_skips_merged_branches() {
|
||||
let out = log(&h, LogQuery { first_parent: true, ..Default::default() });
|
||||
assert_eq!(ids(&out), vec![h.c3.clone(), h.m.clone(), h.c2.clone(), h.c1.clone()]);
|
||||
assert_eq!(parents_of(&out, &h.m), vec![h.c2.clone()]);
|
||||
assert_eq!(out.iter().find(|c| c.id == h.m).unwrap().parents, vec![h.c2.clone(), h.s1.clone()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -201,19 +210,26 @@ fn a_user_filter_links_shown_commits_past_hidden_ones() {
|
||||
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
|
||||
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
|
||||
.iter()
|
||||
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
|
||||
.map(|c| {
|
||||
(
|
||||
c.summary.as_str(),
|
||||
c.graph_parents.as_ref().unwrap().iter().map(String::as_str).collect(),
|
||||
if c.graph_dashed.iter().any(|d| *d) { c.graph_dashed.clone() } else { Vec::new() },
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
|
||||
"m2's parent o1 is hidden: m2 links to m1, dashed"
|
||||
);
|
||||
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
|
||||
assert!(!log.iter().any(|c| c.graph_parents.as_ref().unwrap().contains(&o1)));
|
||||
assert_eq!(log[1].parents, vec![o1]);
|
||||
|
||||
// A page ends at m2: walking on still links it to m1.
|
||||
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
|
||||
assert_eq!(page[1].graph_parents.as_deref().unwrap(), std::slice::from_ref(&m1));
|
||||
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
|
||||
let _ = m3;
|
||||
|
||||
@@ -256,6 +256,11 @@ pub fn set_writable(path: &Path, writable: bool) -> IdeResult<()> {
|
||||
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
ops::rename(from, to)
|
||||
}
|
||||
/// Checks source existence and destination conflicts without mutation, using
|
||||
/// the same filesystem identity rules as [`rename`].
|
||||
pub fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
ops::validate_rename(from, to)
|
||||
}
|
||||
/// Moves to the OS trash (recoverable), like IDEA's safe delete for files.
|
||||
/// Deletes permanently when the trash is unavailable.
|
||||
pub fn delete(path: &Path) -> IdeResult<()> {
|
||||
|
||||
@@ -88,7 +88,7 @@ fn same_file(a: &Path, b: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
pub(crate) fn validate_rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if !exists(from) {
|
||||
return Err(not_found(from));
|
||||
}
|
||||
@@ -99,6 +99,14 @@ pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if exists(to) && !same_file(from, to) {
|
||||
return Err(already_exists(to));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
validate_rename(from, to)?;
|
||||
if from == to {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_parent(to)?;
|
||||
match fs::rename(from, to) {
|
||||
Ok(()) => Ok(()),
|
||||
|
||||
@@ -21,6 +21,7 @@ blake3.workspace = true
|
||||
tracing.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
|
||||
@@ -401,8 +401,11 @@ impl Documents {
|
||||
if edit.files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
// Validate project rules before path resolution can erase linked components.
|
||||
crate::file_ops::validate_workspace_edit_paths(edit)?;
|
||||
let mut edit = normalize_edit(edit)?;
|
||||
let captured = !edit.preconditions.is_empty();
|
||||
crate::file_ops::expand_workspace_edit(&mut edit)?;
|
||||
self.prepare_workspace_edit(&mut edit)?;
|
||||
self.validate_preconditions(&edit.preconditions)?;
|
||||
for file in &edit.files {
|
||||
|
||||
@@ -120,6 +120,8 @@ impl FileMoves {
|
||||
if moves.is_empty() {
|
||||
return Err(IdeError::invalid("nothing to move"));
|
||||
}
|
||||
let expanded = crate::file_ops::expand_moves(moves)?;
|
||||
let moves = expanded.as_slice();
|
||||
for m in moves {
|
||||
if !m.from.exists() {
|
||||
return Err(IdeError::not_found(format!("{} does not exist", m.from.display())));
|
||||
@@ -145,16 +147,9 @@ impl FileMoves {
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
let mut done: Vec<&FileMove> = Vec::new();
|
||||
for m in moves {
|
||||
if let Err(e) = ide_vfs::rename(&m.from, &m.to) {
|
||||
for d in done.iter().rev() {
|
||||
let _ = ide_vfs::rename(&d.to, &d.from);
|
||||
}
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
done.push(m);
|
||||
if let Err(e) = crate::file_ops::move_files(moves) {
|
||||
restore(documents, &paths, &before);
|
||||
return Err(e);
|
||||
}
|
||||
let mut edited = Vec::with_capacity(paths.len());
|
||||
for (p, before) in paths.iter().zip(before) {
|
||||
@@ -196,27 +191,21 @@ impl FileMoves {
|
||||
.ok_or_else(|| IdeError::not_found("the refactoring can no longer be undone"))?;
|
||||
records.remove(i)
|
||||
};
|
||||
for m in &record.moves {
|
||||
if !m.to.exists() {
|
||||
return Err(IdeError::not_found(format!("{} no longer exists", m.to.display())));
|
||||
}
|
||||
if m.from.exists() {
|
||||
return Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::AlreadyExists,
|
||||
format!("{} exists again", m.from.display()),
|
||||
));
|
||||
}
|
||||
let back: Vec<_> =
|
||||
record.moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
|
||||
if let Err(e) = crate::file_ops::move_files(&back) {
|
||||
self.records.lock().push(record);
|
||||
return Err(e);
|
||||
}
|
||||
let mut outcome = UndoOutcome::default();
|
||||
for m in record.moves.iter().rev() {
|
||||
ide_vfs::rename(&m.to, &m.from)?;
|
||||
outcome.moves.push(FileMove { from: m.to.clone(), to: m.from.clone() });
|
||||
}
|
||||
let back: Vec<FileMove> = outcome.moves.clone();
|
||||
let mut outcome = UndoOutcome { moves: back.clone(), conflicts: Vec::new() };
|
||||
for f in &record.edited {
|
||||
let path = moved_path(&f.path, &back);
|
||||
match read_text(documents, &path) {
|
||||
Ok(now) if now == f.after => write_text(documents, &path, &f.before)?,
|
||||
Ok(now) if now == f.after => {
|
||||
if write_text(documents, &path, &f.before).is_err() {
|
||||
outcome.conflicts.push(path.into());
|
||||
}
|
||||
}
|
||||
Ok(now) if now == f.before => {}
|
||||
_ => outcome.conflicts.push(path.into()),
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Project-aware file operations. Plain files retain the VFS behavior; project
|
||||
//! rules may expand a resource operation before the common transaction runs.
|
||||
|
||||
use crate::FileMove;
|
||||
use ide_core::{IdeError, IdeResult, WorkspaceEdit};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod project;
|
||||
|
||||
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
project::copy(from, to).unwrap_or_else(|| ide_vfs::copy(from, to))
|
||||
}
|
||||
|
||||
pub fn delete(path: &Path) -> IdeResult<()> {
|
||||
project::delete(path).unwrap_or_else(|| ide_vfs::delete(path))
|
||||
}
|
||||
|
||||
/// Files Local History must capture before deletion.
|
||||
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
project::deletion_paths(path).unwrap_or_else(|| Ok(vec![path.to_path_buf()]))
|
||||
}
|
||||
|
||||
pub fn rename(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if !project::has_move_rules(from, to) {
|
||||
return ide_vfs::rename(from, to);
|
||||
}
|
||||
let moves = expand_moves(&[FileMove { from: from.into(), to: to.into() }])?;
|
||||
move_files(&moves)
|
||||
}
|
||||
|
||||
pub(crate) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
|
||||
let expanded = project::expand_moves(moves)?;
|
||||
validate_moves(&expanded)?;
|
||||
Ok(expanded)
|
||||
}
|
||||
|
||||
fn path_key(path: &Path) -> PathBuf {
|
||||
#[cfg(windows)]
|
||||
return path.to_string_lossy().replace('\\', "/").to_lowercase().into();
|
||||
#[cfg(not(windows))]
|
||||
path.to_path_buf()
|
||||
}
|
||||
|
||||
fn validate_moves(moves: &[FileMove]) -> IdeResult<()> {
|
||||
let mut sources = std::collections::HashSet::new();
|
||||
let mut targets = std::collections::HashSet::new();
|
||||
for m in moves {
|
||||
ide_vfs::validate_rename(&m.from, &m.to)?;
|
||||
let from = path_key(&m.from);
|
||||
let to = path_key(&m.to);
|
||||
if to != from && to.starts_with(&from) {
|
||||
return Err(IdeError::invalid("Cannot move a directory into itself"));
|
||||
}
|
||||
if !sources.insert(from) || !targets.insert(to) {
|
||||
return Err(IdeError::invalid("Duplicate source or destination in file operation"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn move_files(moves: &[FileMove]) -> IdeResult<()> {
|
||||
execute_moves(moves, &|from, to| project::rename_entry(from, to).unwrap_or_else(|| ide_vfs::rename(from, to)))
|
||||
}
|
||||
|
||||
/// Executes an expanded batch. Rollback never overwrites a newly occupied path;
|
||||
/// failures report where the caller can recover the remaining files.
|
||||
pub(crate) fn execute_moves(moves: &[FileMove], rename: &dyn Fn(&Path, &Path) -> IdeResult<()>) -> IdeResult<()> {
|
||||
validate_moves(moves)?;
|
||||
for (i, m) in moves.iter().enumerate() {
|
||||
if let Err(mut error) = rename(&m.from, &m.to) {
|
||||
for done in moves[..i].iter().rev() {
|
||||
if let Err(rollback) =
|
||||
ide_vfs::validate_rename(&done.to, &done.from).and_then(|()| rename(&done.to, &done.from))
|
||||
{
|
||||
error.message.push_str(&format!(
|
||||
"; rollback {} -> {} failed: {rollback}",
|
||||
done.to.display(),
|
||||
done.from.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
project::validate_workspace_edit_paths(edit)
|
||||
}
|
||||
|
||||
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
|
||||
project::expand_workspace_edit(edit)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! The integration boundary for project-specific resource rules. These rules
|
||||
//! never replace the plain VFS path unless an operation touches project assets.
|
||||
|
||||
use crate::{FileMove, unity::assets};
|
||||
use ide_core::{FileEdit, IdeResult, WorkspaceEdit};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub(super) fn has_move_rules(from: &Path, to: &Path) -> bool {
|
||||
assets::project_root(from).is_some() || assets::project_root(to).is_some()
|
||||
}
|
||||
|
||||
pub(super) fn copy(from: &Path, to: &Path) -> Option<IdeResult<()>> {
|
||||
has_move_rules(from, to).then(|| assets::copy(from, to))
|
||||
}
|
||||
|
||||
pub(super) fn delete(path: &Path) -> Option<IdeResult<()>> {
|
||||
assets::project_root(path).map(|_| assets::delete(path))
|
||||
}
|
||||
|
||||
pub(super) fn deletion_paths(path: &Path) -> Option<IdeResult<Vec<PathBuf>>> {
|
||||
assets::project_root(path).map(|_| assets::deletion_paths(path))
|
||||
}
|
||||
|
||||
pub(super) fn rename_entry(from: &Path, to: &Path) -> Option<IdeResult<()>> {
|
||||
has_move_rules(from, to).then(|| assets::move_one(from, to))
|
||||
}
|
||||
|
||||
pub(super) fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
|
||||
if moves.iter().any(|m| has_move_rules(&m.from, &m.to)) { assets::expand_moves(moves) } else { Ok(moves.to_vec()) }
|
||||
}
|
||||
|
||||
fn has_edit_rules(edit: &WorkspaceEdit) -> bool {
|
||||
edit.files.iter().any(|file| match file {
|
||||
FileEdit::Rename { from, to } => has_move_rules(from, to),
|
||||
FileEdit::Delete { path, .. } => assets::project_root(path).is_some(),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
if has_edit_rules(edit) {
|
||||
assets::validate_workspace_edit_paths(edit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
|
||||
if has_edit_rules(edit) {
|
||||
assets::expand_workspace_edit(edit)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,6 +7,7 @@ pub mod documents;
|
||||
pub mod editorconfig;
|
||||
pub mod file_defaults;
|
||||
pub mod file_moves;
|
||||
pub mod file_ops;
|
||||
pub mod file_refs;
|
||||
pub mod history;
|
||||
pub mod memory;
|
||||
@@ -15,6 +16,7 @@ pub mod recent;
|
||||
pub mod roots;
|
||||
pub mod settings;
|
||||
pub mod text_refs;
|
||||
pub mod unity;
|
||||
pub mod window_groups;
|
||||
pub mod workspace;
|
||||
|
||||
|
||||
@@ -62,7 +62,10 @@ const ROOT_NAMES: &[&str] = &[
|
||||
pub fn affects_roots(rel: &Path) -> bool {
|
||||
let mut names = rel.iter().rev().filter_map(|c| c.to_str());
|
||||
let Some(name) = names.next() else { return false };
|
||||
BUILD_FILES.contains(&name) || ROOT_NAMES.contains(&name) || names.next() == Some("src")
|
||||
BUILD_FILES.contains(&name)
|
||||
|| ROOT_NAMES.contains(&name)
|
||||
|| matches!(name, "Assets" | "Packages" | "ProjectSettings" | "ProjectVersion.txt")
|
||||
|| names.next() == Some("src")
|
||||
}
|
||||
|
||||
/// Detects roots below `root`; paths in the result are unique and sorted.
|
||||
@@ -77,6 +80,29 @@ pub fn detect(root: &Path) -> Vec<SourceFolder> {
|
||||
break;
|
||||
}
|
||||
let dir = if rel.is_empty() { root.to_path_buf() } else { root.join(&rel) };
|
||||
if crate::unity::is_project(&dir) {
|
||||
for (name, kind) in
|
||||
[("Assets", RootKind::Source), ("Packages", RootKind::Source), ("ProjectSettings", RootKind::Resources)]
|
||||
{
|
||||
if dir.join(name).is_dir() {
|
||||
out.push(SourceFolder {
|
||||
path: join(&rel, name),
|
||||
kind,
|
||||
detected_from: Some(join(&rel, crate::unity::VERSION_FILE)),
|
||||
});
|
||||
}
|
||||
}
|
||||
// Exclude before the index/watchers start, even if Unity creates its caches later.
|
||||
for name in crate::unity::GENERATED_DIRS {
|
||||
out.push(SourceFolder {
|
||||
path: join(&rel, name),
|
||||
kind: RootKind::Excluded,
|
||||
detected_from: Some(join(&rel, crate::unity::VERSION_FILE)),
|
||||
});
|
||||
}
|
||||
// Assets and PackageCache may contain arbitrary build files; they aren't nested modules.
|
||||
continue;
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else { continue };
|
||||
let mut files: Vec<String> = Vec::new();
|
||||
let mut dirs: Vec<String> = Vec::new();
|
||||
@@ -244,6 +270,43 @@ mod tests {
|
||||
roots.iter().map(|r| format!("{}={:?}@{}", r.path, r.kind, r.detected_from.as_deref().unwrap_or(""))).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unity_roots_exclude_future_caches_and_do_not_scan_assets_as_modules() {
|
||||
let dir = tree(&[
|
||||
"Assets/Scripts/Player.cs",
|
||||
"Assets/Plugins/Tool/package.json",
|
||||
"ProjectSettings/ProjectVersion.txt",
|
||||
"Packages/manifest.json",
|
||||
"Library/PackageCache/tool/Cargo.toml",
|
||||
]);
|
||||
let roots = detect(dir.path());
|
||||
assert!(roots.iter().any(|r| r.path == "Assets" && r.kind == RootKind::Source));
|
||||
assert!(roots.iter().any(|r| r.path == "Packages" && r.kind == RootKind::Source));
|
||||
assert!(!roots.iter().any(|r| r.path.starts_with("Assets/") || r.path.starts_with("Library/")));
|
||||
let effective = super::super::ProjectRoots::compute(roots, &Default::default());
|
||||
for name in crate::unity::GENERATED_DIRS {
|
||||
assert!(effective.excluded_globs().contains(&format!("/{name}")));
|
||||
}
|
||||
for path in ["Assets", "Packages", "ProjectSettings", crate::unity::VERSION_FILE] {
|
||||
assert!(affects_roots(Path::new(path)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_unity_roots_are_anchored_without_excluding_other_projects() {
|
||||
let dir = tree(&[
|
||||
"game/Assets/Scripts/Player.cs",
|
||||
"game/ProjectSettings/ProjectVersion.txt",
|
||||
"server/Cargo.toml",
|
||||
"server/src/main.rs",
|
||||
"Library/Source.cs",
|
||||
]);
|
||||
let roots = super::super::ProjectRoots::compute(detect(dir.path()), &Default::default());
|
||||
assert!(roots.excluded_globs().contains(&"/game/Library".to_owned()));
|
||||
assert!(!roots.excluded_globs().contains(&"/Library".to_owned()));
|
||||
assert!(roots.roots.iter().any(|r| r.path == "server/src" && r.kind == RootKind::Source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_conventional_roots_and_target() {
|
||||
let dir = tree(&[
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//! Unity project identity. Generated C# solutions are optional, not root markers.
|
||||
|
||||
pub(crate) mod assets;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::io::Read;
|
||||
use std::path::Path;
|
||||
|
||||
pub const VERSION_FILE: &str = "ProjectSettings/ProjectVersion.txt";
|
||||
/// Root-relative caches and build outputs, including folders not created yet.
|
||||
pub const GENERATED_DIRS: &[&str] = &["Library", "Temp", "Obj", "obj", "Logs", "Build", "Builds", "UserSettings"];
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UnityProjectInfo {
|
||||
pub editor_version: Option<String>,
|
||||
/// File names relative to the Unity root, in stable order.
|
||||
pub solutions: Vec<String>,
|
||||
pub projects: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn is_project(root: &Path) -> bool {
|
||||
root.join("Assets").is_dir() && root.join(VERSION_FILE).is_file()
|
||||
}
|
||||
|
||||
pub fn detect(root: &Path) -> Option<UnityProjectInfo> {
|
||||
if !is_project(root) {
|
||||
return None;
|
||||
}
|
||||
let mut text = String::new();
|
||||
let _ = std::fs::File::open(root.join(VERSION_FILE)).and_then(|f| f.take(64 * 1024).read_to_string(&mut text));
|
||||
let editor_version = text.lines().find_map(|line| {
|
||||
let (key, value) = line.trim_start_matches('\u{feff}').split_once(':')?;
|
||||
let value = value.trim();
|
||||
(key.trim() == "m_EditorVersion" && !value.is_empty() && value.len() <= 128).then(|| value.to_owned())
|
||||
});
|
||||
let mut info = UnityProjectInfo { editor_version, solutions: Vec::new(), projects: Vec::new() };
|
||||
if let Ok(entries) = std::fs::read_dir(root) {
|
||||
for entry in entries.flatten() {
|
||||
if !entry.file_type().is_ok_and(|t| t.is_file()) {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
match entry.path().extension().and_then(|e| e.to_str()).map(str::to_ascii_lowercase).as_deref() {
|
||||
Some("sln" | "slnx") => info.solutions.push(name),
|
||||
Some("csproj") => info.projects.push(name),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
info.solutions.sort();
|
||||
info.projects.sort();
|
||||
Some(info)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn detects_fresh_and_legacy_projects_without_packages_or_solutions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root = temp.path();
|
||||
std::fs::create_dir(root.join("Assets")).unwrap();
|
||||
assert!(detect(root).is_none());
|
||||
std::fs::create_dir(root.join("ProjectSettings")).unwrap();
|
||||
std::fs::write(
|
||||
root.join(VERSION_FILE),
|
||||
"\u{feff}m_EditorVersion: 2022.3.62f1\r\nm_EditorVersionWithRevision: ignored\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
let info = detect(root).unwrap();
|
||||
assert_eq!(info.editor_version.as_deref(), Some("2022.3.62f1"));
|
||||
assert!(info.solutions.is_empty() && info.projects.is_empty());
|
||||
std::fs::write(root.join("Game.sln"), "").unwrap();
|
||||
std::fs::write(root.join("Game.slnx"), "").unwrap();
|
||||
std::fs::write(root.join("Assembly-CSharp.csproj"), "").unwrap();
|
||||
std::fs::create_dir(root.join("NotAProject.csproj")).unwrap();
|
||||
let info = detect(root).unwrap();
|
||||
assert_eq!(info.solutions, ["Game.sln", "Game.slnx"]);
|
||||
assert_eq!(info.projects, ["Assembly-CSharp.csproj"]);
|
||||
std::fs::write(root.join(VERSION_FILE), "malformed").unwrap();
|
||||
assert_eq!(detect(root).unwrap().editor_version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_csharp_projects_are_not_unity_projects() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(temp.path().join("App.csproj"), "").unwrap();
|
||||
std::fs::create_dir(temp.path().join("Assets")).unwrap();
|
||||
assert!(detect(temp.path()).is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! Filesystem operations for assets under a Unity project's Assets directory.
|
||||
//! Metadata moves with the asset; duplication gets new GUIDs before publication.
|
||||
|
||||
use crate::{file_moves::FileMove, file_ops::execute_moves};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
mod duplicate;
|
||||
mod workspace_edit;
|
||||
pub(crate) use workspace_edit::{expand_workspace_edit, validate_workspace_edit_paths};
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub fn meta_path(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".meta");
|
||||
name.into()
|
||||
}
|
||||
|
||||
/// Includes Assets itself so callers can protect the asset root.
|
||||
pub fn project_root(path: &Path) -> Option<PathBuf> {
|
||||
path.ancestors().find_map(|p| {
|
||||
(p.file_name().is_some_and(|n| if cfg!(windows) { n.eq_ignore_ascii_case("Assets") } else { n == "Assets" })
|
||||
&& p.parent().is_some_and(super::is_project))
|
||||
.then(|| p.parent().unwrap().to_path_buf())
|
||||
})
|
||||
}
|
||||
|
||||
fn exists(path: &Path) -> bool {
|
||||
fs::symlink_metadata(path).is_ok()
|
||||
}
|
||||
|
||||
fn is_meta(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("meta"))
|
||||
}
|
||||
|
||||
fn validate_asset(path: &Path) -> IdeResult<()> {
|
||||
if let Some(root) = project_root(path) {
|
||||
if key(path) == key(&root.join("Assets")) {
|
||||
return Err(IdeError::invalid("The Unity Assets root cannot be moved, copied or deleted"));
|
||||
}
|
||||
if is_meta(path) && exists(&path.with_extension("")) {
|
||||
return Err(IdeError::invalid(
|
||||
"Select the Unity asset instead of its .meta file; metadata is handled together",
|
||||
));
|
||||
}
|
||||
}
|
||||
no_links(path)
|
||||
}
|
||||
|
||||
fn no_links(path: &Path) -> IdeResult<()> {
|
||||
if path.components().any(|c| matches!(c, Component::ParentDir)) {
|
||||
return Err(IdeError::invalid("Unity asset operations require paths without '..'"));
|
||||
}
|
||||
// Trust the opened project's location, including a linked root or parent
|
||||
// (macOS /var and /tmp). Check inside it, including Library staging paths.
|
||||
// The outermost root prevents a nested Unity project from hiding a link.
|
||||
let root = path.ancestors().filter(|p| super::is_project(p)).last();
|
||||
for p in path.ancestors().take_while(|p| Some(*p) != root) {
|
||||
match fs::symlink_metadata(p) {
|
||||
Ok(m) if is_link(&m) => {
|
||||
return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", p.display())));
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(e.into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_link(metadata: &fs::Metadata) -> bool {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
// Also reject directory junctions and other reparse points.
|
||||
metadata.file_attributes() & 0x400 != 0
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
metadata.file_type().is_symlink()
|
||||
}
|
||||
}
|
||||
|
||||
fn key(path: &Path) -> String {
|
||||
let s = path.to_string_lossy().replace('\\', "/");
|
||||
if cfg!(windows) { s.to_lowercase() } else { s }
|
||||
}
|
||||
|
||||
fn vacant(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if exists(to) {
|
||||
if !exists(from) {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display())));
|
||||
}
|
||||
ide_vfs::validate_rename(from, to)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Expand before applying reference edits, so orphan metadata conflicts cannot
|
||||
/// leave a partially refactored asset. Metadata goes first on publication.
|
||||
pub fn expand_moves(moves: &[FileMove]) -> IdeResult<Vec<FileMove>> {
|
||||
let mut out = Vec::new();
|
||||
for m in moves {
|
||||
let unity = project_root(&m.from).is_some() || project_root(&m.to).is_some();
|
||||
if unity {
|
||||
// A caller may explicitly select both an asset and its metadata.
|
||||
if is_meta(&m.from)
|
||||
&& moves
|
||||
.iter()
|
||||
.any(|a| meta_path(&a.from) == m.from.to_path_buf() && meta_path(&a.to) == m.to.to_path_buf())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
validate_asset(&m.from)?;
|
||||
validate_asset(&m.to)?;
|
||||
let from = meta_path(&m.from);
|
||||
let to = meta_path(&m.to);
|
||||
no_links(&from)?;
|
||||
no_links(&to)?;
|
||||
vacant(&from, &to)?;
|
||||
if exists(&from) {
|
||||
out.push(FileMove { from: from.into(), to: to.into() });
|
||||
}
|
||||
}
|
||||
out.push(m.clone());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub(crate) fn move_one(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if project_root(from).is_some() || project_root(to).is_some() {
|
||||
no_links(from)?;
|
||||
no_links(to)?;
|
||||
if let Some(parent) = to.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
// A failed cross-volume copy/delete cannot be rolled back reliably.
|
||||
// Keep paired Unity moves atomic per entry and let rename report it.
|
||||
fs::rename(from, to)?;
|
||||
Ok(())
|
||||
} else {
|
||||
ide_vfs::rename(from, to)
|
||||
}
|
||||
}
|
||||
|
||||
/// The paths Local History must snapshot before an asset deletion.
|
||||
pub fn deletion_paths(path: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
validate_asset(path)?;
|
||||
let meta = meta_path(path);
|
||||
no_links(&meta)?;
|
||||
let mut paths = vec![path.to_path_buf()];
|
||||
if exists(&meta) {
|
||||
paths.push(meta);
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
fn staging(root: &Path) -> IdeResult<PathBuf> {
|
||||
let parent = root.join("Library/OpenIDEA/AssetOperations");
|
||||
no_links(&parent)?;
|
||||
fs::create_dir_all(&parent)?;
|
||||
let dir = parent.join(uuid::Uuid::new_v4().simple().to_string());
|
||||
fs::create_dir(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn delete(path: &Path) -> IdeResult<()> {
|
||||
delete_with(path, &ide_vfs::delete)
|
||||
}
|
||||
|
||||
fn delete_with(path: &Path, remove: &dyn Fn(&Path) -> IdeResult<()>) -> IdeResult<()> {
|
||||
let paths = deletion_paths(path)?;
|
||||
let root = project_root(path).ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?;
|
||||
let stage = staging(&root)?;
|
||||
let payload = stage.join("files");
|
||||
fs::create_dir(&payload)?;
|
||||
let moves: Vec<_> = paths
|
||||
.iter()
|
||||
.map(|p| FileMove { from: p.clone().into(), to: payload.join(p.file_name().unwrap()).into() })
|
||||
.collect();
|
||||
// Recycle-bin restore returns the group to staging. Keep its destinations
|
||||
// with it so originals can also be recovered without the IDE's history.
|
||||
let manifest = stage.join("restore-paths.json");
|
||||
fs::write(&manifest, serde_json::to_vec_pretty(&paths).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
// Never automatically delete this staging directory on failure: it may
|
||||
// contain originals when rollback fails.
|
||||
if let Err(e) = execute_moves(&moves, &move_one) {
|
||||
if fs::remove_dir(&payload).is_ok() {
|
||||
let _ = fs::remove_file(&manifest);
|
||||
let _ = fs::remove_dir(&stage);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
if let Err(mut e) = remove(&stage) {
|
||||
let back: Vec<_> = moves.iter().rev().map(|m| FileMove { from: m.to.clone(), to: m.from.clone() }).collect();
|
||||
if let Err(rollback) = execute_moves(&back, &move_one) {
|
||||
e.message.push_str(&format!("; recovery files at {}: {rollback}", stage.display()));
|
||||
}
|
||||
if fs::remove_dir(&payload).is_ok() {
|
||||
let _ = fs::remove_file(&manifest);
|
||||
let _ = fs::remove_dir(&stage);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn copy(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
let root = project_root(to)
|
||||
.or_else(|| project_root(from))
|
||||
.ok_or_else(|| IdeError::invalid("Expected a Unity asset path"))?;
|
||||
validate_asset(from)?;
|
||||
validate_asset(to)?;
|
||||
if key(from) == key(to) || key(to).starts_with(&(key(from) + "/")) {
|
||||
return Err(IdeError::invalid("Cannot copy an asset onto or into itself"));
|
||||
}
|
||||
if exists(to) {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", to.display())));
|
||||
}
|
||||
let src_meta = meta_path(from);
|
||||
let dst_meta = meta_path(to);
|
||||
no_links(&src_meta)?;
|
||||
no_links(&dst_meta)?;
|
||||
// Copies never overwrite metadata, including orphan destination metadata.
|
||||
if exists(&dst_meta) {
|
||||
return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{} already exists", dst_meta.display())));
|
||||
}
|
||||
let stage = staging(&root)?;
|
||||
let result = (|| {
|
||||
let asset = stage.join(from.file_name().ok_or_else(|| IdeError::invalid("Missing asset name"))?);
|
||||
duplicate::copy_tree(from, &asset)?;
|
||||
let meta = meta_path(&asset);
|
||||
if exists(&src_meta) {
|
||||
duplicate::copy_tree(&src_meta, &meta)?;
|
||||
}
|
||||
if project_root(to).is_some() {
|
||||
duplicate::regenerate(&stage)?;
|
||||
}
|
||||
let mut moves = Vec::new();
|
||||
if exists(&meta) {
|
||||
moves.push(FileMove { from: meta.into(), to: dst_meta.into() });
|
||||
}
|
||||
moves.push(FileMove { from: asset.into(), to: to.into() });
|
||||
execute_moves(&moves, &move_one)
|
||||
})();
|
||||
// Only disposable copies live here, never the source assets.
|
||||
let _ = fs::remove_dir_all(&stage);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
use super::*;
|
||||
use std::io::Read;
|
||||
|
||||
const TEXT_LIMIT: u64 = 32 * 1024 * 1024;
|
||||
|
||||
pub(super) fn copy_tree(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
no_links(from)?;
|
||||
let metadata = fs::symlink_metadata(from)?;
|
||||
if metadata.is_dir() {
|
||||
fs::create_dir(to)?;
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
copy_tree(&entry.path(), &to.join(entry.file_name()))?;
|
||||
}
|
||||
} else if metadata.is_file() {
|
||||
fs::copy(from, to)?;
|
||||
} else {
|
||||
return Err(IdeError::invalid(format!("Not a regular Unity asset: {}", from.display())));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn files(dir: &Path, out: &mut Vec<PathBuf>) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
files(&entry.path(), out)?;
|
||||
} else {
|
||||
out.push(entry.path());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_text(path: &Path) -> IdeResult<String> {
|
||||
let mut bytes = Vec::new();
|
||||
fs::File::open(path)?.take(TEXT_LIMIT + 1).read_to_end(&mut bytes)?;
|
||||
if bytes.len() as u64 > TEXT_LIMIT {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Unity serialized asset exceeds the 32 MiB copy limit: {}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
String::from_utf8(bytes)
|
||||
.map_err(|_| IdeError::invalid(format!("Cannot rewrite binary Unity metadata: {}", path.display())))
|
||||
}
|
||||
|
||||
fn guid(value: &str) -> bool {
|
||||
value.len() == 32 && value.bytes().all(|b| b.is_ascii_hexdigit()) && value.bytes().any(|b| b != b'0')
|
||||
}
|
||||
|
||||
pub(super) fn regenerate(stage: &Path) -> IdeResult<()> {
|
||||
let mut paths = Vec::new();
|
||||
files(stage, &mut paths)?;
|
||||
let mut ids = HashMap::new();
|
||||
for path in paths.iter().filter(|p| is_meta(p)) {
|
||||
let text = read_text(path)?;
|
||||
let values: Vec<_> = text
|
||||
.trim_start_matches('\u{feff}')
|
||||
.lines()
|
||||
.filter_map(|l| l.strip_prefix("guid:").map(str::trim))
|
||||
.collect();
|
||||
if values.len() != 1 || !guid(values[0]) {
|
||||
return Err(IdeError::invalid(format!("Missing or invalid GUID in {}", path.display())));
|
||||
}
|
||||
if ids.insert(values[0].to_ascii_lowercase(), uuid::Uuid::new_v4().simple().to_string()).is_some() {
|
||||
return Err(IdeError::invalid("Duplicate source GUIDs; resolve them in Unity before copying"));
|
||||
}
|
||||
}
|
||||
for path in paths {
|
||||
let ext = path.extension().unwrap_or_default().to_string_lossy().to_ascii_lowercase();
|
||||
if ext == "asmdef" || ext == "asmref" {
|
||||
let text = read_text(&path)?;
|
||||
let mut json: serde_json::Value =
|
||||
serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
if rewrite_json(&mut json, &ids) {
|
||||
fs::write(&path, serde_json::to_string_pretty(&json).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let mut header = [0; 32];
|
||||
let n = fs::File::open(&path)?.read(&mut header)?;
|
||||
let header = String::from_utf8_lossy(&header[..n]);
|
||||
let header = header.trim_start_matches('\u{feff}');
|
||||
let yaml = header.starts_with("%YAML") || header.starts_with("--- !u!");
|
||||
if !yaml && !is_meta(&path) {
|
||||
// These formats may be binary under Mixed/Force Binary. We cannot
|
||||
// safely preserve internal references without Unity deserialization.
|
||||
if matches!(
|
||||
ext.as_str(),
|
||||
"asset"
|
||||
| "prefab"
|
||||
| "unity"
|
||||
| "mat"
|
||||
| "anim"
|
||||
| "controller"
|
||||
| "overridecontroller"
|
||||
| "playable"
|
||||
| "mask"
|
||||
| "rendertexture"
|
||||
| "physicmaterial"
|
||||
| "physicsmaterial2d"
|
||||
| "lighting"
|
||||
| "terrainlayer"
|
||||
| "guiskin"
|
||||
| "flare"
|
||||
| "cubemap"
|
||||
| "spriteatlas"
|
||||
| "spriteatlasv2"
|
||||
| "preset"
|
||||
| "shadervariants"
|
||||
| "giparams"
|
||||
| "lightingdata"
|
||||
| "fontsettings"
|
||||
| "texture2d"
|
||||
| "mesh"
|
||||
) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"Cannot copy binary Unity serialized asset {}; use Force Text serialization in Unity",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let before = read_text(&path)?;
|
||||
let after = rewrite_yaml(&before, &ids);
|
||||
if before != after {
|
||||
fs::write(&path, after)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn rewrite_json(value: &mut serde_json::Value, ids: &HashMap<String, String>) -> bool {
|
||||
match value {
|
||||
serde_json::Value::String(s) => {
|
||||
if let Some(id) = s.strip_prefix("GUID:").and_then(|id| ids.get(&id.to_ascii_lowercase())) {
|
||||
*s = format!("GUID:{id}");
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
serde_json::Value::Array(a) => {
|
||||
let mut changed = false;
|
||||
for v in a {
|
||||
changed |= rewrite_json(v, ids);
|
||||
}
|
||||
changed
|
||||
}
|
||||
serde_json::Value::Object(o) => {
|
||||
let mut changed = false;
|
||||
for v in o.values_mut() {
|
||||
changed |= rewrite_json(v, ids);
|
||||
}
|
||||
changed
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Patch GUID scalar tokens without reserializing Unity's YAML dialect. Quoted
|
||||
/// strings, comments and indented block scalars are not asset references.
|
||||
fn rewrite_yaml(text: &str, ids: &HashMap<String, String>) -> String {
|
||||
let mut bytes = text.as_bytes().to_vec();
|
||||
let mut offset = 0;
|
||||
let mut block_indent = None;
|
||||
let mut quote = None;
|
||||
for line in text.split_inclusive('\n') {
|
||||
let raw = line.as_bytes();
|
||||
let indent = raw.iter().take_while(|b| **b == b' ').count();
|
||||
if block_indent.is_some_and(|n| indent > n || line.trim().is_empty()) {
|
||||
offset += raw.len();
|
||||
continue;
|
||||
}
|
||||
block_indent = None;
|
||||
let mut i = 0;
|
||||
while i < raw.len() {
|
||||
let b = raw[i];
|
||||
if let Some(q) = quote {
|
||||
if q == b'"' && b == b'\\' {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if b == q {
|
||||
if q == b'\'' && raw.get(i + 1) == Some(&q) {
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
quote = None;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if b == b'#' && (i == 0 || raw[i - 1].is_ascii_whitespace()) {
|
||||
break;
|
||||
}
|
||||
if b == b'"' || b == b'\'' {
|
||||
let prefix = line[..i].trim();
|
||||
// Apostrophes inside plain scalars (e.g. Hero's Sword) do not
|
||||
// open a quoted scalar or suppress references on later lines.
|
||||
if prefix.is_empty() || prefix.ends_with([':', '-', '[', '{', ',']) {
|
||||
quote = Some(b);
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (b == b'|' || b == b'>') && line[..i].trim_end().ends_with([':', '-']) {
|
||||
block_indent = Some(indent);
|
||||
break;
|
||||
}
|
||||
if raw[i..].starts_with(b"guid:") {
|
||||
let prefix = line[..i].trim_start_matches('\u{feff}').trim_end();
|
||||
if prefix.is_empty() || prefix.ends_with(['{', ',']) {
|
||||
let start = i + 5 + raw[i + 5..].iter().take_while(|b| **b == b' ' || **b == b'\t').count();
|
||||
let end = start + 32;
|
||||
if end <= raw.len()
|
||||
&& raw[start..end].iter().all(u8::is_ascii_hexdigit)
|
||||
&& (end == raw.len()
|
||||
|| raw[end].is_ascii_whitespace()
|
||||
|| matches!(raw[end], b',' | b'}' | b'#'))
|
||||
&& let Some(new) = ids.get(&line[start..end].to_ascii_lowercase())
|
||||
{
|
||||
bytes[offset + start..offset + end].copy_from_slice(new.as_bytes());
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Non-ASCII bytes cannot begin any token handled above.
|
||||
i += 1;
|
||||
}
|
||||
offset += raw.len();
|
||||
}
|
||||
String::from_utf8(bytes).expect("GUID substitutions preserve UTF-8")
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use super::*;
|
||||
use crate::file_ops::{move_files, rename};
|
||||
use crate::{documents::Documents, file_moves::FileMoves};
|
||||
use ide_core::{LanguageId, WorkspaceEdit};
|
||||
use std::sync::Arc;
|
||||
|
||||
mod links;
|
||||
|
||||
const A: &str = "11111111111111111111111111111111";
|
||||
const B: &str = "22222222222222222222222222222222";
|
||||
const C: &str = "33333333333333333333333333333333";
|
||||
|
||||
fn project() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(tmp.path().join("Assets")).unwrap();
|
||||
fs::create_dir(tmp.path().join("ProjectSettings")).unwrap();
|
||||
fs::write(tmp.path().join(super::super::VERSION_FILE), "m_EditorVersion: 6000.0.1f1").unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
fn asset(root: &Path, name: &str, content: &str, id: &str) -> PathBuf {
|
||||
let path = root.join("Assets").join(name);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(&path, content).unwrap();
|
||||
fs::write(meta_path(&path), format!("fileFormatVersion: 2\r\nguid: {id}\r\nImporter:\r\n setting: keep\r\n"))
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn id(path: &Path) -> String {
|
||||
fs::read_to_string(meta_path(path))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.find_map(|l| l.strip_prefix("guid: ").map(str::to_owned))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_preserves_metadata_and_undo_retries_after_conflict() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "class Player {}", A);
|
||||
let dst = src.with_file_name("Hero.cs");
|
||||
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
let stack = FileMoves::new();
|
||||
let moves = [FileMove { from: src.clone().into(), to: dst.clone().into() }];
|
||||
let token = stack.perform(&docs, None, &moves, &WorkspaceEdit::default(), "Rename", &|_| Ok(())).unwrap();
|
||||
assert_eq!(id(&dst), A);
|
||||
assert!(!meta_path(&src).exists());
|
||||
fs::write(meta_path(&src), "conflict").unwrap();
|
||||
assert!(stack.undo(&docs, token).is_err());
|
||||
assert!(stack.can_undo(token));
|
||||
assert!(dst.exists());
|
||||
fs::remove_file(meta_path(&src)).unwrap();
|
||||
let undone = stack.undo(&docs, token).unwrap();
|
||||
assert_eq!(undone.moves.len(), 2);
|
||||
assert_eq!(id(&src), A);
|
||||
assert!(!dst.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_metadata_conflicts_and_standalone_metadata_are_protected() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let dst = src.with_file_name("Hero.cs");
|
||||
fs::write(meta_path(&dst), "orphan").unwrap();
|
||||
assert!(rename(&src, &dst).is_err());
|
||||
assert!(copy(&src, &dst).is_err());
|
||||
assert!(src.exists() && !dst.exists());
|
||||
assert_eq!(id(&src), A);
|
||||
assert!(rename(&meta_path(&src), &src.with_file_name("Other.meta")).is_err());
|
||||
assert!(delete_with(&meta_path(&src), &|_| panic!("must reject before deletion")).is_err());
|
||||
assert!(rename(&tmp.path().join("Assets"), &tmp.path().join("Renamed")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_copy_regenerates_guids_and_only_internal_references() {
|
||||
let tmp = project();
|
||||
let root = tmp.path();
|
||||
let text = format!(
|
||||
"%YAML 1.1\n--- !u!1 &1\nObject:\n internal: {{fileID: 1, guid: {B}, type: 3}}\n external: {{fileID: 2, guid: {C}, type: 3}}\n quoted: \"guid: {B}\"\n block: |\n guid: {B}\n"
|
||||
);
|
||||
let src = asset(root, "Group/Scene.prefab", &text, A);
|
||||
asset(root, "Group/Script.cs", "// untouched", B);
|
||||
fs::write(root.join("Assets/Group.meta"), format!("guid: {C}\nfolderAsset: yes\n")).unwrap();
|
||||
// An actual external id not copied into Group.
|
||||
let external = "44444444444444444444444444444444";
|
||||
fs::write(
|
||||
&src,
|
||||
text.replace(&format!("external: {{fileID: 2, guid: {C}"), &format!("external: {{fileID: 2, guid: {external}")),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.join("Assets/Group/Assembly.asmdef"), format!(r#"{{"references":["GUID:{B}","GUID:{external}"]}}"#))
|
||||
.unwrap();
|
||||
let to = root.join("Assets/Group Copy");
|
||||
copy(&root.join("Assets/Group"), &to).unwrap();
|
||||
let script = id(&to.join("Script.cs"));
|
||||
assert_ne!(script, B);
|
||||
assert_ne!(id(&to.join("Scene.prefab")), A);
|
||||
assert_ne!(id(&to), C);
|
||||
assert_eq!(id(&src), A);
|
||||
let copied = fs::read_to_string(to.join("Scene.prefab")).unwrap();
|
||||
assert!(copied.contains(&format!("internal: {{fileID: 1, guid: {script}")));
|
||||
assert!(copied.contains(&format!("external: {{fileID: 2, guid: {external}")));
|
||||
assert!(copied.contains(&format!("quoted: \"guid: {B}\"")));
|
||||
assert!(copied.contains(&format!("block: |\n guid: {B}")));
|
||||
assert!(fs::read_to_string(meta_path(&to.join("Script.cs"))).unwrap().contains("setting: keep\r\n"));
|
||||
let assembly = fs::read_to_string(to.join("Assembly.asmdef")).unwrap();
|
||||
assert!(assembly.contains(&format!("GUID:{script}")) && assembly.contains(external));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_or_binary_copy_never_publishes_partial_assets() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Group/Data.asset", "binary data", A);
|
||||
let from = src.parent().unwrap();
|
||||
let to = from.with_file_name("Copy");
|
||||
assert!(copy(from, &to).is_err());
|
||||
assert!(!to.exists() && !meta_path(&to).exists());
|
||||
assert_eq!(id(&src), A);
|
||||
fs::write(&src, "%YAML 1.1\n--- !u!1 &1\n").unwrap();
|
||||
fs::write(meta_path(&src), "guid: invalid\n").unwrap();
|
||||
assert!(copy(from, &to).is_err());
|
||||
assert!(!to.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deletion_groups_sidecar_and_restores_both_on_trash_failure() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "code", A);
|
||||
assert_eq!(deletion_paths(&src).unwrap(), vec![src.clone(), meta_path(&src)]);
|
||||
let error = delete_with(&src, &|stage| {
|
||||
assert!(stage.join("files/Player.cs").exists());
|
||||
assert!(stage.join("files/Player.cs.meta").exists());
|
||||
let destinations: Vec<PathBuf> =
|
||||
serde_json::from_slice(&fs::read(stage.join("restore-paths.json")).unwrap()).unwrap();
|
||||
assert_eq!(destinations, vec![src.clone(), meta_path(&src)]);
|
||||
Err(IdeError::internal("trash unavailable"))
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.message.contains("trash unavailable"));
|
||||
assert!(src.exists());
|
||||
assert_eq!(id(&src), A);
|
||||
delete_with(&src, &ide_vfs::delete_permanently).unwrap();
|
||||
assert!(!src.exists() && !meta_path(&src).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selecting_both_asset_and_sidecar_does_not_move_twice() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let dst = src.with_file_name("Hero.cs");
|
||||
let expanded = expand_moves(&[
|
||||
FileMove { from: src.clone().into(), to: dst.clone().into() },
|
||||
FileMove { from: meta_path(&src).into(), to: meta_path(&dst).into() },
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(expanded.len(), 2);
|
||||
move_files(&expanded).unwrap();
|
||||
assert_eq!(id(&dst), A);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_unity_operations_do_not_touch_adjacent_metadata() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let src = tmp.path().join("file.txt");
|
||||
let dst = tmp.path().join("copy.txt");
|
||||
fs::write(&src, "text").unwrap();
|
||||
fs::write(meta_path(&src), "unrelated").unwrap();
|
||||
crate::file_ops::copy(&src, &dst).unwrap();
|
||||
assert!(!meta_path(&dst).exists());
|
||||
rename(&src, &tmp.path().join("moved.txt")).unwrap();
|
||||
assert!(meta_path(&src).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_second_move_rolls_back_metadata() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let meta_dst = src.with_file_name("Moved.cs.meta");
|
||||
let blocker = tmp.path().join("Assets/blocked");
|
||||
fs::write(&blocker, "not a directory").unwrap();
|
||||
let moves = [
|
||||
FileMove { from: meta_path(&src).into(), to: meta_dst.clone().into() },
|
||||
FileMove { from: src.clone().into(), to: blocker.join("Moved.cs").into() },
|
||||
];
|
||||
assert!(move_files(&moves).is_err());
|
||||
assert_eq!(id(&src), A);
|
||||
assert!(src.exists() && !meta_dst.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yaml_scalar_text_does_not_change_reference_detection() {
|
||||
let tmp = project();
|
||||
let text = format!(
|
||||
"%YAML 1.1\n--- !u!1 &1\nObject:\n name: Hero's Sword\n self: {{fileID: 1, guid: {A}, type: 3}}\n text: \"escaped \\\" guid: {A}\"\n multiline: 'value\n guid: {A}'\n selfAgain: {{fileID: 1, guid: {A}, type: 3}}\n"
|
||||
);
|
||||
let src = asset(tmp.path(), "Player.prefab", &text, A);
|
||||
let dst = src.with_file_name("Copied.prefab");
|
||||
copy(&src, &dst).unwrap();
|
||||
let new_id = id(&dst);
|
||||
let copied = fs::read_to_string(&dst).unwrap();
|
||||
assert!(copied.contains(&format!("self: {{fileID: 1, guid: {new_id}")));
|
||||
assert!(copied.contains(&format!("selfAgain: {{fileID: 1, guid: {new_id}")));
|
||||
assert!(copied.contains(&format!("multiline: 'value\n guid: {A}'")));
|
||||
assert!(copied.contains(&format!("text: \"escaped \\\" guid: {A}\"")));
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn case_only_rename_keeps_metadata() {
|
||||
let tmp = project();
|
||||
let src = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let dst = src.with_file_name("player.cs");
|
||||
rename(&src, &dst).unwrap();
|
||||
assert_eq!(id(&dst), A);
|
||||
assert!(fs::read_dir(dst.parent().unwrap()).unwrap().any(|e| e.unwrap().file_name() == "player.cs.meta"));
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
use super::*;
|
||||
use ide_core::FileEdit;
|
||||
|
||||
fn dir_link(target: &Path, link: &Path) {
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(target, link).unwrap();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(link.to_string_lossy().replace('/', "\\"))
|
||||
.arg(target.to_string_lossy().replace('/', "\\"))
|
||||
.creation_flags(0x08000000)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_parent_and_project_root_support_asset_operations_and_undo() {
|
||||
for parent_alias in [false, true] {
|
||||
let tmp = project();
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
let link = aliases.path().join("alias");
|
||||
let root = if parent_alias {
|
||||
dir_link(tmp.path().parent().unwrap(), &link);
|
||||
link.join(tmp.path().file_name().unwrap())
|
||||
} else {
|
||||
dir_link(tmp.path(), &link);
|
||||
link.clone()
|
||||
};
|
||||
let from = asset(&root, "Player.cs", "class Player {}", A);
|
||||
let to = root.join("Assets/New/Nested/Hero.cs");
|
||||
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
let moves = FileMoves::new();
|
||||
let token = moves
|
||||
.perform(
|
||||
&docs,
|
||||
None,
|
||||
&[FileMove { from: from.clone().into(), to: to.clone().into() }],
|
||||
&WorkspaceEdit::default(),
|
||||
"Move",
|
||||
&|_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(id(&to), A);
|
||||
moves.undo(&docs, token).unwrap();
|
||||
assert_eq!(id(&from), A);
|
||||
copy(&from, &to).unwrap();
|
||||
assert_ne!(id(&to), A);
|
||||
assert_eq!(id(&from), A);
|
||||
delete_with(&to, &ide_vfs::delete_permanently).unwrap();
|
||||
assert!(!to.exists() && !meta_path(&to).exists());
|
||||
let intermediate = root.join("Assets/Intermediate.cs");
|
||||
docs.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Rename { from: from.clone().into(), to: intermediate.clone().into() },
|
||||
FileEdit::Rename { from: intermediate.into(), to: to.clone().into() },
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(id(&to), A);
|
||||
assert!(!from.exists() && !meta_path(&from).exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn internal_directory_links_reject_asset_operations_before_mutation() {
|
||||
for relative in ["Assets/Linked", "Library", "Library/OpenIDEA", "Library/OpenIDEA/AssetOperations"] {
|
||||
let tmp = project();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("sentinel"), "keep").unwrap();
|
||||
let from = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let link = tmp.path().join(relative);
|
||||
fs::create_dir_all(link.parent().unwrap()).unwrap();
|
||||
dir_link(outside.path(), &link);
|
||||
let to = if relative.starts_with("Assets") { link.join("Copy.cs") } else { from.with_file_name("Copy.cs") };
|
||||
assert!(copy(&from, &to).unwrap_err().message.contains("do not follow links"));
|
||||
if relative.starts_with("Assets") {
|
||||
assert!(rename(&from, &to).is_err());
|
||||
let docs =
|
||||
Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
assert!(
|
||||
docs.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![FileEdit::Rename { from: from.clone().into(), to: to.clone().into() },],
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
let sentinel = link.join("sentinel");
|
||||
assert!(
|
||||
docs.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![FileEdit::Delete { path: sentinel.into(), recursive: false },],
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
} else {
|
||||
assert!(delete_with(&from, &|_| panic!("must reject before removal")).is_err());
|
||||
}
|
||||
assert_eq!(id(&from), A);
|
||||
assert!(from.exists() && !to.exists() && !meta_path(&to).exists());
|
||||
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
|
||||
assert_eq!(fs::read_dir(outside.path()).unwrap().count(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_assets_root_and_recursive_copy_links_are_rejected() {
|
||||
let tmp = project();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::remove_dir(tmp.path().join("Assets")).unwrap();
|
||||
dir_link(outside.path(), &tmp.path().join("Assets"));
|
||||
let from = asset(tmp.path(), "Player.cs", "code", A);
|
||||
assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err());
|
||||
assert!(deletion_paths(&from).is_err());
|
||||
assert_eq!(id(&from), A);
|
||||
|
||||
let tmp = project();
|
||||
let from = asset(tmp.path(), "Group/Player.cs", "code", A);
|
||||
dir_link(outside.path(), &tmp.path().join("Assets/Group/Linked"));
|
||||
let to = tmp.path().join("Assets/Copy");
|
||||
assert!(copy(from.parent().unwrap(), &to).is_err());
|
||||
assert!(!to.exists() && !meta_path(&to).exists());
|
||||
assert_eq!(id(&from), A);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn linked_asset_and_sidecar_leaves_are_rejected() {
|
||||
for sidecar in [false, true] {
|
||||
let tmp = project();
|
||||
let outside = tempfile::NamedTempFile::new().unwrap();
|
||||
let from = asset(tmp.path(), "Player.cs", "code", A);
|
||||
let path = if sidecar { meta_path(&from) } else { from.clone() };
|
||||
fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(outside.path(), &path).unwrap();
|
||||
assert!(rename(&from, &from.with_file_name("Hero.cs")).is_err());
|
||||
assert!(copy(&from, &from.with_file_name("Copy.cs")).is_err());
|
||||
assert!(deletion_paths(&from).is_err());
|
||||
assert!(fs::symlink_metadata(&path).unwrap().file_type().is_symlink());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_project_operations_validate_each_root_and_keep_metadata() {
|
||||
let source = project();
|
||||
let destination = project();
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
let a = aliases.path().join("source");
|
||||
let b = aliases.path().join("destination");
|
||||
dir_link(source.path(), &a);
|
||||
dir_link(destination.path(), &b);
|
||||
let from = asset(&a, "Player.cs", "code", A);
|
||||
let copied = b.join("Assets/Copied.cs");
|
||||
copy(&from, &copied).unwrap();
|
||||
assert_ne!(id(&copied), A);
|
||||
assert_eq!(id(&from), A);
|
||||
let moved = b.join("Assets/Moved.cs");
|
||||
rename(&from, &moved).unwrap();
|
||||
assert_eq!(id(&moved), A);
|
||||
assert!(!from.exists() && !meta_path(&from).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_links_do_not_become_trusted_by_importing_into_unity() {
|
||||
let tmp = project();
|
||||
let external = tempfile::tempdir().unwrap();
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
fs::write(external.path().join("File.cs"), "code").unwrap();
|
||||
let link = aliases.path().join("external");
|
||||
dir_link(external.path(), &link);
|
||||
let from = link.join("File.cs");
|
||||
let to = tmp.path().join("Assets/Imported.cs");
|
||||
assert!(copy(&from, &to).is_err());
|
||||
assert!(rename(&from, &to).is_err());
|
||||
assert!(from.exists() && !to.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folder_rename_cannot_hide_linked_children_in_later_transaction_operations() {
|
||||
let tmp = project();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("sentinel"), "keep").unwrap();
|
||||
let from = tmp.path().join("Assets/Group");
|
||||
fs::create_dir(&from).unwrap();
|
||||
dir_link(outside.path(), &from.join("Linked"));
|
||||
let to = tmp.path().join("Assets/Moved");
|
||||
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
let error = docs
|
||||
.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Rename { from: from.clone().into(), to: to.clone().into() },
|
||||
FileEdit::Delete {
|
||||
path: (if cfg!(windows) { tmp.path().join("Assets/moved") } else { to.clone() })
|
||||
.join("Linked/sentinel")
|
||||
.into(),
|
||||
recursive: false,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap_err();
|
||||
assert!(error.message.contains("do not follow links"));
|
||||
assert!(from.exists() && !to.exists());
|
||||
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn directory_transactions_reject_nested_links_before_snapshotting() {
|
||||
for delete in [false, true] {
|
||||
let tmp = project();
|
||||
let outside = tempfile::tempdir().unwrap();
|
||||
fs::write(outside.path().join("sentinel"), "keep").unwrap();
|
||||
let from = tmp.path().join("Assets/Group");
|
||||
fs::create_dir(&from).unwrap();
|
||||
dir_link(outside.path(), &from.join("Linked"));
|
||||
let to = tmp.path().join("Assets/Moved");
|
||||
let file = if delete {
|
||||
FileEdit::Delete { path: from.clone().into(), recursive: true }
|
||||
} else {
|
||||
FileEdit::Rename { from: from.clone().into(), to: to.clone().into() }
|
||||
};
|
||||
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
let error = docs.apply_workspace_edit(&WorkspaceEdit { files: vec![file], ..Default::default() }).unwrap_err();
|
||||
assert!(error.message.contains("do not follow links"));
|
||||
assert!(from.exists() && !to.exists());
|
||||
assert_eq!(fs::read_to_string(outside.path().join("sentinel")).unwrap(), "keep");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Include sidecars in the document transaction's preflight, recovery and undo.
|
||||
use super::{exists, fs, is_link, key, meta_path, no_links, project_root, validate_asset};
|
||||
use ide_core::{FileEdit, IdeError, IdeResult, WorkspaceEdit};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn validate_workspace_edit_paths(edit: &WorkspaceEdit) -> IdeResult<()> {
|
||||
for file in &edit.files {
|
||||
let validate = |path: &Path| -> IdeResult<()> {
|
||||
validate_asset(path)?;
|
||||
no_links(&meta_path(path))?;
|
||||
no_nested_links(path)
|
||||
};
|
||||
match file {
|
||||
FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => {
|
||||
validate(from)?;
|
||||
validate(to)?;
|
||||
}
|
||||
FileEdit::Delete { path, .. } if project_root(path).is_some() => validate(path)?,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Transactions snapshot directories recursively. Reject linked descendants
|
||||
// before backup, including junctions and children renamed by a later operation.
|
||||
fn no_nested_links(path: &Path) -> IdeResult<()> {
|
||||
let metadata = match fs::symlink_metadata(path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if is_link(&metadata) {
|
||||
return Err(IdeError::invalid(format!("Unity asset operations do not follow links: {}", path.display())));
|
||||
}
|
||||
if metadata.is_dir() {
|
||||
for entry in fs::read_dir(path)? {
|
||||
no_nested_links(&entry?.path())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Resolve against the state produced by earlier operations without touching disk.
|
||||
// This also handles renaming a folder before renaming/deleting a child asset.
|
||||
fn present(path: &Path, preceding: &[FileEdit]) -> bool {
|
||||
let mut path = path.to_path_buf();
|
||||
for op in preceding.iter().rev() {
|
||||
match op {
|
||||
FileEdit::Rename { from, to } => {
|
||||
if let Ok(relative) = path.strip_prefix(to) {
|
||||
// Joining an empty suffix adds a separator to a file path;
|
||||
// that makes an existing sidecar look like a missing directory.
|
||||
path = if relative.as_os_str().is_empty() { from.to_path_buf() } else { from.join(relative) };
|
||||
} else if path.starts_with(from) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
FileEdit::Delete { path: deleted, .. } if path.starts_with(deleted) => return false,
|
||||
FileEdit::Create { path: created, .. } if path == created.to_path_buf() => return true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
exists(&path)
|
||||
}
|
||||
|
||||
pub(crate) fn expand_workspace_edit(edit: &mut WorkspaceEdit) -> IdeResult<()> {
|
||||
let mut files = Vec::new();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Rename { from, to } if project_root(from).is_some() || project_root(to).is_some() => {
|
||||
validate_asset(from)?;
|
||||
validate_asset(to)?;
|
||||
let source = meta_path(from);
|
||||
let destination = meta_path(to);
|
||||
no_links(&source)?;
|
||||
no_links(&destination)?;
|
||||
if present(&destination, &files) && key(&source) != key(&destination) {
|
||||
return Err(IdeError::invalid(format!("{} already exists", destination.display())));
|
||||
}
|
||||
if present(&source, &files) {
|
||||
files.push(FileEdit::Rename { from: source.into(), to: destination.into() });
|
||||
}
|
||||
}
|
||||
FileEdit::Delete { path, .. } if project_root(path).is_some() => {
|
||||
validate_asset(path)?;
|
||||
let meta = meta_path(path);
|
||||
no_links(&meta)?;
|
||||
if present(&meta, &files) {
|
||||
files.push(FileEdit::Delete { path: meta.into(), recursive: false });
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
files.push(file.clone());
|
||||
}
|
||||
edit.files = files;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
use ide_workspace::file_ops;
|
||||
use std::{fs, path::Path};
|
||||
|
||||
fn metadata_path(path: &Path) -> std::path::PathBuf {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".meta");
|
||||
name.into()
|
||||
}
|
||||
|
||||
fn unity_project(root: &Path) {
|
||||
fs::create_dir_all(root.join("Assets")).unwrap();
|
||||
fs::create_dir_all(root.join("ProjectSettings")).unwrap();
|
||||
fs::write(root.join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1").unwrap();
|
||||
}
|
||||
|
||||
fn directory_alias(target: &Path, alias: &Path) {
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(target, alias).unwrap();
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
let output = std::process::Command::new("cmd")
|
||||
.args(["/c", "mklink", "/J"])
|
||||
.arg(alias.to_string_lossy().replace('/', "\\"))
|
||||
.arg(target.to_string_lossy().replace('/', "\\"))
|
||||
.creation_flags(0x08000000)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_rename_through_an_alias_keeps_vfs_same_file_behavior() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
let alias = aliases.path().join("project");
|
||||
directory_alias(dir.path(), &alias);
|
||||
let from = dir.path().join("File.txt");
|
||||
let to = alias.join("File.txt");
|
||||
fs::write(&from, "keep").unwrap();
|
||||
ide_vfs::rename(&from, &to).unwrap();
|
||||
file_ops::rename(&from, &to).unwrap();
|
||||
let copied = alias.join("Copy.txt");
|
||||
file_ops::copy(&to, &copied).unwrap();
|
||||
assert_eq!(fs::read_to_string(copied).unwrap(), "keep");
|
||||
assert_eq!(fs::read_to_string(from).unwrap(), "keep");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_files_and_non_assets_in_unity_keep_adjacent_metadata_untouched() {
|
||||
for unity in [false, true] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
if unity {
|
||||
unity_project(dir.path());
|
||||
}
|
||||
let from = dir.path().join("File.txt");
|
||||
let copied = dir.path().join("Copy.txt");
|
||||
let renamed = dir.path().join("file.txt");
|
||||
fs::write(&from, "data").unwrap();
|
||||
fs::write(metadata_path(&from), "unrelated").unwrap();
|
||||
file_ops::copy(&from, &copied).unwrap();
|
||||
assert!(!metadata_path(&copied).exists());
|
||||
file_ops::rename(&from, &renamed).unwrap();
|
||||
assert_eq!(fs::read_to_string(&renamed).unwrap(), "data");
|
||||
assert_eq!(file_ops::deletion_paths(&renamed).unwrap(), [renamed]);
|
||||
assert_eq!(fs::read_to_string(metadata_path(&from)).unwrap(), "unrelated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_move_and_undo_apply_rules_only_to_unity_assets() {
|
||||
use ide_core::{LanguageId, WorkspaceEdit};
|
||||
use ide_workspace::{Documents, FileMove, FileMoves};
|
||||
use std::sync::Arc;
|
||||
|
||||
let unity = tempfile::tempdir().unwrap();
|
||||
unity_project(unity.path());
|
||||
let plain = tempfile::tempdir().unwrap();
|
||||
let aliases = tempfile::tempdir().unwrap();
|
||||
let alias = aliases.path().join("plain");
|
||||
directory_alias(plain.path(), &alias);
|
||||
let source = unity.path().join("Assets/Player.cs");
|
||||
let destination = unity.path().join("Assets/Hero.cs");
|
||||
let ordinary = alias.join("plain.txt");
|
||||
let ordinary_to = alias.join("moved.txt");
|
||||
fs::write(&source, "class Player {}").unwrap();
|
||||
fs::write(metadata_path(&source), "guid: 11111111111111111111111111111111\n").unwrap();
|
||||
fs::write(&ordinary, "plain").unwrap();
|
||||
fs::write(metadata_path(&ordinary), "unrelated").unwrap();
|
||||
let docs = Documents::new(Arc::new(|_| {}), Box::new(|_: &Path, _: Option<&str>| LanguageId::new("plaintext")));
|
||||
let moves = FileMoves::new();
|
||||
let token = moves
|
||||
.perform(
|
||||
&docs,
|
||||
None,
|
||||
&[
|
||||
FileMove { from: ordinary.clone().into(), to: ordinary_to.clone().into() },
|
||||
FileMove { from: source.clone().into(), to: destination.clone().into() },
|
||||
],
|
||||
&WorkspaceEdit::default(),
|
||||
"Move",
|
||||
&|_| Ok(()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(destination.exists() && metadata_path(&destination).exists());
|
||||
assert!(ordinary_to.exists() && !metadata_path(&ordinary_to).exists());
|
||||
assert!(metadata_path(&ordinary).exists());
|
||||
moves.undo(&docs, token).unwrap();
|
||||
assert!(source.exists() && metadata_path(&source).exists());
|
||||
assert!(ordinary.exists() && metadata_path(&ordinary).exists());
|
||||
assert!(!destination.exists() && !metadata_path(&destination).exists());
|
||||
|
||||
let blocker = alias.join("blocked");
|
||||
fs::write(&blocker, "not a directory").unwrap();
|
||||
assert!(
|
||||
moves
|
||||
.perform(
|
||||
&docs,
|
||||
None,
|
||||
&[
|
||||
FileMove { from: source.clone().into(), to: destination.clone().into() },
|
||||
FileMove { from: ordinary.clone().into(), to: blocker.join("file.txt").into() },
|
||||
],
|
||||
&WorkspaceEdit::default(),
|
||||
"Move",
|
||||
&|_| Ok(()),
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(source.exists() && metadata_path(&source).exists());
|
||||
assert!(ordinary.exists() && metadata_path(&ordinary).exists());
|
||||
assert!(!destination.exists() && !metadata_path(&destination).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vfs_operations_remain_unaware_of_unity_metadata() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
unity_project(dir.path());
|
||||
let from = dir.path().join("Assets/Player.cs");
|
||||
let to = dir.path().join("Assets/Hero.cs");
|
||||
fs::write(&from, "code").unwrap();
|
||||
fs::write(metadata_path(&from), "metadata").unwrap();
|
||||
ide_vfs::rename(&from, &to).unwrap();
|
||||
assert!(to.exists() && !metadata_path(&to).exists());
|
||||
assert!(metadata_path(&from).exists());
|
||||
}
|
||||
@@ -9,6 +9,95 @@ fn documents() -> Documents {
|
||||
Documents::new(Arc::new(NullSink), Box::new(|_, _| LanguageId::plain_text()))
|
||||
}
|
||||
|
||||
fn meta(path: &std::path::Path) -> std::path::PathBuf {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(".meta");
|
||||
name.into()
|
||||
}
|
||||
|
||||
fn unity_project() -> tempfile::TempDir {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("Assets")).unwrap();
|
||||
std::fs::create_dir_all(tmp.path().join("ProjectSettings")).unwrap();
|
||||
std::fs::write(tmp.path().join("ProjectSettings/ProjectVersion.txt"), "m_EditorVersion: 2022.3.40f1\n").unwrap();
|
||||
tmp
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unity_rename_chain_and_delete_share_metadata_undo() {
|
||||
let tmp = unity_project();
|
||||
let original = tmp.path().join("Assets/Player.cs");
|
||||
let renamed = tmp.path().join("Assets/Hero.cs");
|
||||
std::fs::write(&original, "old").unwrap();
|
||||
std::fs::write(meta(&original), "guid: unchanged").unwrap();
|
||||
let (docs, events) = recorded_documents();
|
||||
docs.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() },
|
||||
FileEdit::Delete { path: renamed.clone().into(), recursive: false },
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
for path in [&original, &renamed, &meta(&original), &meta(&renamed)] {
|
||||
assert!(!path.exists(), "{} should be removed", path.display());
|
||||
}
|
||||
docs.undo_workspace_edit(undo_id(&events)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&original).unwrap(), "old");
|
||||
assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: unchanged");
|
||||
assert!(!renamed.exists() && !meta(&renamed).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unity_metadata_conflict_keeps_earlier_edits_unchanged() {
|
||||
let tmp = unity_project();
|
||||
let original = tmp.path().join("Assets/Player.cs");
|
||||
let renamed = tmp.path().join("Assets/Hero.cs");
|
||||
std::fs::write(&original, "old").unwrap();
|
||||
std::fs::write(meta(&original), "guid: original").unwrap();
|
||||
std::fs::write(meta(&renamed), "guid: orphan").unwrap();
|
||||
assert!(
|
||||
documents()
|
||||
.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(&original, "new"),
|
||||
FileEdit::Rename { from: original.clone().into(), to: renamed.clone().into() }
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(&original).unwrap(), "old");
|
||||
assert_eq!(std::fs::read_to_string(meta(&original)).unwrap(), "guid: original");
|
||||
assert_eq!(std::fs::read_to_string(meta(&renamed)).unwrap(), "guid: orphan");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unity_folder_rename_then_child_rename_preserves_metadata() {
|
||||
let tmp = unity_project();
|
||||
let folder = tmp.path().join("Assets/Before");
|
||||
let moved = tmp.path().join("Assets/After");
|
||||
std::fs::create_dir(&folder).unwrap();
|
||||
std::fs::write(meta(&folder), "folder guid").unwrap();
|
||||
std::fs::write(folder.join("Player.cs"), "old").unwrap();
|
||||
std::fs::write(meta(&folder.join("Player.cs")), "script guid").unwrap();
|
||||
let (docs, events) = recorded_documents();
|
||||
docs.apply_workspace_edit(&WorkspaceEdit {
|
||||
files: vec![
|
||||
FileEdit::Rename { from: folder.clone().into(), to: moved.clone().into() },
|
||||
FileEdit::Rename { from: moved.join("Player.cs").into(), to: moved.join("Hero.cs").into() },
|
||||
],
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(meta(&moved)).unwrap(), "folder guid");
|
||||
assert_eq!(std::fs::read_to_string(meta(&moved.join("Hero.cs"))).unwrap(), "script guid");
|
||||
assert!(!meta(&moved.join("Player.cs")).exists());
|
||||
docs.undo_workspace_edit(undo_id(&events)).unwrap();
|
||||
assert_eq!(std::fs::read_to_string(meta(&folder.join("Player.cs"))).unwrap(), "script guid");
|
||||
assert!(!moved.exists() && !meta(&moved).exists());
|
||||
}
|
||||
|
||||
fn edit(path: &Path, text: &str) -> FileEdit {
|
||||
FileEdit::Edit {
|
||||
path: path.to_owned().into(),
|
||||
|
||||
@@ -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.
|
||||
+4
-1
@@ -20,7 +20,10 @@ The fixtures are standalone: `rust-app` declares its own `[workspace]` and
|
||||
|
||||
The language smoke test (`crates/ide-host/tests/language_smoke.rs`) runs completion,
|
||||
navigation, diagnostics, quick fixes, formatting and rename on these projects with real
|
||||
language servers: when you change a fixture, run it for that language.
|
||||
language servers, `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests), and `smoke/debug.test.ts`
|
||||
debugs its entry point from the "Set a breakpoint here" line: when you change a fixture, run
|
||||
them for that language.
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ const config: KnipConfig = {
|
||||
"scripts/**/*.ts",
|
||||
"perf/**/*.ts",
|
||||
"e2e/**/*.ts",
|
||||
"smoke/**/*.ts",
|
||||
],
|
||||
// Plugins' web pages are projects of their own (own package.json, checked by plugins/build.sh and CI).
|
||||
ignore: ["plugins/**/web-src/**"],
|
||||
|
||||
@@ -10,7 +10,7 @@ tree-sitter = "csharp"
|
||||
[[language-servers]]
|
||||
name = "csharp-ls"
|
||||
command = "csharp-ls"
|
||||
root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props"]
|
||||
root-markers = ["*.sln", "*.slnx", "*.csproj", "global.json", "Directory.Build.props", "ProjectSettings/ProjectVersion.txt"]
|
||||
install-hint = "dotnet tool install --global csharp-ls"
|
||||
|
||||
[debugger]
|
||||
|
||||
@@ -24,6 +24,14 @@ useAliasesForRenames = false
|
||||
[language-servers.initialization-options.javascript.preferences]
|
||||
useAliasesForRenames = false
|
||||
|
||||
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
|
||||
# doesn't prompt after the move.
|
||||
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[[language-servers]]
|
||||
name = "typescript-language-server"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -23,6 +23,14 @@ useAliasesForRenames = false
|
||||
[language-servers.initialization-options.javascript.preferences]
|
||||
useAliasesForRenames = false
|
||||
|
||||
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
|
||||
# doesn't prompt after the move.
|
||||
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[[language-servers]]
|
||||
name = "typescript-language-server"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -23,6 +23,14 @@ useAliasesForRenames = false
|
||||
[language-servers.initialization-options.javascript.preferences]
|
||||
useAliasesForRenames = false
|
||||
|
||||
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
|
||||
# doesn't prompt after the move.
|
||||
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[[language-servers]]
|
||||
name = "typescript-language-server"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -23,6 +23,14 @@ useAliasesForRenames = false
|
||||
[language-servers.initialization-options.javascript.preferences]
|
||||
useAliasesForRenames = false
|
||||
|
||||
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
|
||||
# doesn't prompt after the move.
|
||||
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[[language-servers]]
|
||||
name = "typescript-language-server"
|
||||
tsserver-project = true
|
||||
|
||||
@@ -30,6 +30,14 @@ useAliasesForRenames = false
|
||||
[language-servers.initialization-options.javascript.preferences]
|
||||
useAliasesForRenames = false
|
||||
|
||||
# Moving a file: the IDE asks for the import updates up front (preview, undo); the server
|
||||
# doesn't prompt after the move.
|
||||
[language-servers.initialization-options.typescript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[language-servers.initialization-options.javascript.updateImportsOnFileMove]
|
||||
enabled = "never"
|
||||
|
||||
[[language-servers]]
|
||||
name = "oxlint"
|
||||
command = "oxlint"
|
||||
|
||||
+2
-1
@@ -12,11 +12,12 @@
|
||||
"tauri": "node scripts/tauri.ts",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"smoke": "vitest run --config smoke/vitest.config.ts",
|
||||
"lint": "oxlint",
|
||||
"format": "oxfmt",
|
||||
"format:check": "oxfmt --check",
|
||||
"knip": "knip",
|
||||
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit",
|
||||
"typecheck": "tsrs --noEmit && tsrs -p tsconfig.node.json --noEmit && tsrs -p perf --noEmit && tsrs -p scripts --noEmit && tsrs -p smoke --noEmit",
|
||||
"gen:bindings": "cargo test -p openidea --test export_bindings",
|
||||
"gen:windows-icons": "node scripts/gen-windows-icons.ts",
|
||||
"gen:angular-grammar": "lezer-generator src/editor/core/cm/angular/angular.grammar --typeScript -o src/editor/core/cm/angular/parser.ts",
|
||||
|
||||
+18
-17
@@ -125,23 +125,23 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?`, `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `tools`), `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name, or an SVG of the package), `location` (`left`, `right`, `bottom`), `web?` (see _Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
@@ -313,6 +313,7 @@ then run `openidea-plugin check`.
|
||||
| `host::refresh_view(view)` | `views.refresh` | The view (or custom editors) render again; call it when the content changed. |
|
||||
| `host::post_view_message(view, text)` | `views.post-message` | To the script of the view's `html` body. |
|
||||
| `host::open_terminal(title, program, args)` | `views.open-terminal` | A terminal tab in the invoking window running the approved `process:<program>` in the project root: output that keeps coming, interactive sessions. |
|
||||
| `host::open_location(path, line, column)` | `views.open-location` | Opens a file inside the project in an editor of the invoking window, the caret at `line`/`column` (0-based, UTF-16): a view's links to code. Needs `project.read`. |
|
||||
| `host::setting(key)`, `setting_bool`, `setting_string`, `setting_number` | `settings.get` | The user's value (project over global) or the schema's default. |
|
||||
|
||||
`run-command` receives an optional string argument: `executeAction("plugin.<plugin id>.<command id>", "arg")`
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
28e4bf10a41bdda011111b0e150ebe96205c57f410bad98c19eea3f1286349d6
|
||||
9ad412cb7b853d40467c67305a1126bb87910d179ecfa1c88e13d4a900367c50
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
7e015486cc916f5edbac542863373fd7204f5bcd534f6c408d5f9d2b72913862
|
||||
49a3c6bf2511ba137457397203244bdfca3d6e10f62dbc7fde9473dab07f6457
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a6cf59e74595e13908e2cbf1752c58665c6963de258413c9d7332d2a7dd9cbd0
|
||||
c199dc52e00d3943882d532e7e6095f7b2f8d7d6026cf1ccaa685dd6300f2319
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
b8dde206a74b222b93e61056d4cbe403617ae240d142e01e91420a2aacdb2da4
|
||||
6047be5eeaa1330d7aa1f182e88ae2f352f244829ad9a01fdc3a2a081bdca95d
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
a681c2abd4a65d6aeb62efd514e9b170f4fca52b0c14d2d380a3b04be0a3fbc6
|
||||
7a3ee410d228d1e39155aa042237d222d23d7f5a45e261086cffc45c6ee1ad32
|
||||
BIN
Binary file not shown.
Loaded 100 of 273 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user