mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ec4032cf7 | ||
|
|
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,8 @@ 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.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
@@ -14,12 +16,18 @@ on:
|
||||
- "fixtures/**"
|
||||
- "Cargo.toml"
|
||||
- "Cargo.lock"
|
||||
- "src/features/run/**"
|
||||
- "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 +61,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 +104,7 @@ jobs:
|
||||
|
||||
- run: cargo test --locked -p ide-host --test language_smoke --no-run
|
||||
- run: cargo test --locked -p ide-host --test language_smoke -- --ignored --exact ${{ matrix.language }}
|
||||
|
||||
- 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 }}$"
|
||||
@@ -69,6 +69,8 @@ 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 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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
@@ -462,6 +464,7 @@ impl Host {
|
||||
index,
|
||||
lsp,
|
||||
repos,
|
||||
external_log_repos: RwLock::default(),
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
@@ -595,6 +598,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 +696,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 +750,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 +1011,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() {
|
||||
|
||||
@@ -612,9 +612,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.
|
||||
|
||||
+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.
|
||||
+3
-1
@@ -20,7 +20,9 @@ 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, and `smoke/run.test.ts` runs each project's run and test configurations
|
||||
(the intentionally failing test included, then Rerun Failed Tests): when you change a
|
||||
fixture, run both 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.
+1
-1
@@ -1 +1 @@
|
||||
3c83bc9559df54457e19e650cdd21bab9d609e22daaa1fc4f596910b70834a34
|
||||
7658a95eb9fc27468cae4b1d20e3b5ab299fde478e07f69ab0fdb4cc10caf11d
|
||||
Binary file not shown.
Loaded 100 of 267 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user