Compare commits

..
Author SHA1 Message Date
l0ng-ai db99fc9cd3 feat(vcs): mark the current worktree in the Worktrees list
The worktree whose path is the window's repository root gets a check before its branch (Current worktree); drive paths compare case-insensitively. Branch names keep their width and the path is cut short instead.
2026-11-05 16:14:54 +08:00
405 changed files with 1593 additions and 16413 deletions

No files matched your search

+1 -2
View File
@@ -9,8 +9,7 @@
<!-- Required. Commands you actually ran and their results; what else this could
affect and how you checked it still works.
Visible UI changes: before/after screenshots (not for removed UI or copy-only
changes). Multi-step interactions: a recording.
UI changes: before/after screenshots. Multi-step interactions: a recording.
Backend changes: logs or command output. -->
## Review focus
-60
View File
@@ -7,9 +7,6 @@ on:
types: [opened, synchronize, reopened]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
# A newer push supersedes the run in progress, on main too.
@@ -22,50 +19,8 @@ env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only
jobs:
# Which parts a pull request touches, so jobs it can't affect skip and leave the
# shared runners to others. Pushes and manual runs always run everything.
changes:
name: Changes
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
pull-requests: read
outputs:
rust: ${{ steps.classify.outputs.rust }}
frontend: ${{ steps.classify.outputs.frontend }}
steps:
- id: classify
env:
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
run: |
set -euo pipefail
if [ "$GITHUB_EVENT_NAME" != pull_request ]; then
echo "rust=true" >> "$GITHUB_OUTPUT"
echo "frontend=true" >> "$GITHUB_OUTPUT"
exit 0
fi
gh api --paginate "repos/$GITHUB_REPOSITORY/pulls/$PR/files" \
--jq '.[] | .filename, (.previous_filename // empty)' > files.txt
cat files.txt
docs='^(docs/|\.agents/|\.claude/|\.idea/|\.github/ISSUE_TEMPLATE/|\.github/pull_request_template\.md$|[^/]+\.md$|LICENSE$|NOTICE$)'
# Frontend-only paths. The Rust code embeds a few files under src/; those stay out.
web='^(src/|e2e/|perf/|public/|scripts/|smoke/|index\.html$|vite\.config\.ts$|tsconfig[^/]*\.json$|knip\.ts$|playwright\.config\.ts$|\.oxlintrc\.json$|\.oxfmtrc\.json$)'
embedded='^src/(bindings\.ts|styles/tokens\.css|features/plugins/views/frame-bridge\.js|platform/keymaps/)'
# A job runs when any changed file falls outside the paths that can't affect it.
rust=false
if grep -Ev "$docs|$web" files.txt | grep -q . || grep -Eq "$embedded" files.txt; then rust=true; fi
frontend=false
if grep -Ev "$docs" files.txt | grep -q .; then frontend=true; fi
echo "rust=$rust frontend=$frontend"
echo "rust=$rust" >> "$GITHUB_OUTPUT"
echo "frontend=$frontend" >> "$GITHUB_OUTPUT"
rust:
name: Rust (${{ matrix.os }})
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -107,9 +62,6 @@ jobs:
# keep that build working.
server:
name: Remote server (linux musl)
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 40
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -125,9 +77,6 @@ jobs:
# own workspaces (the root excludes them), so the jobs above don't see them.
plugins:
name: Plugins (SDK, official, fixtures)
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 30
runs-on: ubuntu-22.04
env:
CARGO_TARGET_DIR: ${{ github.workspace }}/target-plugins
@@ -177,9 +126,6 @@ jobs:
deny:
name: Cargo deny
needs: changes
if: needs.changes.outputs.rust == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
@@ -189,9 +135,6 @@ jobs:
frontend:
name: Frontend
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v5
@@ -209,9 +152,6 @@ jobs:
e2e:
name: E2E
needs: changes
if: needs.changes.outputs.frontend == 'true'
timeout-minutes: 30
runs-on: ubuntu-24.04
# Browsers and their system libraries are preinstalled; keep the tag in step with @playwright/test.
container: mcr.microsoft.com/playwright:v1.64.1-noble
+4 -5
View File
@@ -11,7 +11,10 @@ name: Language smoke
on:
push:
branches: [main]
paths: &paths
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths:
- "crates/**"
- "languages/**"
- "fixtures/**"
@@ -24,10 +27,6 @@ on:
- "package.json"
- "pnpm-lock.yaml"
- ".github/workflows/language-smoke.yml"
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths: *paths
workflow_dispatch:
concurrency:
-10
View File
@@ -37,16 +37,6 @@ jobs:
echo "::error::tag $TAG does not match tauri.conf.json version $app"
exit 1
fi
# A shipped plugin API is frozen: its WIT must be the snapshot api_frozen.rs checks later
# versions against (plugins/README.md, "Evolving the plugin API").
- name: Plugin API snapshot matches wit/
run: |
api() { sed -n 's/^package openidea:plugin@\(.*\);$/\1/p' "$1/plugin.wit"; }
snapshot=sdk/rust/openidea-plugin/tests/api/released
if [ "$(api wit)" != "$(api "$snapshot")" ]; then
echo "::error::plugin API $(api wit) ships without a snapshot ($snapshot is $(api "$snapshot")): copy wit/*.wit to $snapshot/"
exit 1
fi
# The official plugins' public key (from PLUGIN_SIGNING_KEY, the seed
# `openidea-plugin keygen` writes), built into the app: their packages
# must carry its signature whatever the marketplace index lists. Without
+1 -2
View File
@@ -51,8 +51,7 @@ Visual style: dark, low-contrast greys, one cool accent
Follow `CONTRIBUTING.md` and `.github/pull_request_template.md`: one problem per PR
(`Closes #<n>` when it resolves an issue), reproduce first, keep the diff focused, short plain description, and a
**How it was verified** section with commands run, results and, when the change alters what users
see, before/after screenshots (not for UI removed along with a feature, copy-only or non-visual changes).
**How it was verified** section with commands run, results and (for UI) before/after screenshots.
Unclear product decisions → `needs-decision` and stop.
## Skills
+2 -3
View File
@@ -25,9 +25,8 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
- Fill in the template. Keep the description short and in plain words. If it
doesn't fit in a few sentences, the PR is probably too big.
- **How it was verified** is required: the commands you ran and their results,
and what else the change could affect. Changes to what users see need before/after
screenshots; multi-step interactions need a recording. UI that goes away with a removed
feature, copy-only and non-visual changes don't; say what you checked instead.
and what else the change could affect. UI changes need before/after screenshots;
multi-step interactions need a recording.
- If a change needs a product decision you can't make, ask in the issue and stop
rather than guessing.
Generated
+5 -9
View File
@@ -1496,7 +1496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.9",
"syn 2.0.119",
]
[[package]]
@@ -3001,7 +3001,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c48eccd894bd15555b0346470e63491ded94aefe76b3fcb1052084b5a393238"
dependencies = [
"bstr",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -4121,7 +4121,6 @@ dependencies = [
name = "ide-build"
version = "0.1.37"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-core",
@@ -4182,7 +4181,6 @@ dependencies = [
"ide-language",
"memchr",
"parking_lot",
"portable-pty",
"serde",
"serde_json",
"specta",
@@ -4544,7 +4542,6 @@ dependencies = [
"ignore",
"parking_lot",
"serde",
"serde_json",
"specta",
"tempfile",
"tokio",
@@ -6382,7 +6379,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows",
"windows-sys 0.61.2",
]
@@ -8380,7 +8376,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d204abcbdf8e88729306a8d0ca01a79d8a49969fe1f84696909d6dbc4321c1a"
dependencies = [
"foldhash",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -9982,9 +9978,9 @@ dependencies = [
[[package]]
name = "tree-sitter-haskell"
version = "0.24.1"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "781de30e0d1ec8e6db4ed799d36ce1cedaf4684374dada45ebe2a8ea44c3f814"
checksum = "977c51e504548cba13fc27cb5a2edab2124cf6716a1934915d07ab99523b05a4"
dependencies = [
"cc",
"tree-sitter-language",
+1 -8
View File
@@ -16,7 +16,7 @@
&nbsp;·&nbsp;
<a href="#plugins">Plugins</a>
&nbsp;·&nbsp;
<a href="#community">Community</a>
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
</p>
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
@@ -57,13 +57,6 @@ Install them from Settings › Plugins, or start your own with
`openidea-plugin new` — see [Writing OpenIDEA plugins](plugins/README.md) and
the [official plugins](plugins/official). The plugin API is still a preview.
## Community
- [Discord](https://discord.gg/2Zy4yn7gP) — questions, feedback and release news.
- WeChat group — scan to join:
<img src="docs/assets/wechat-group.png" alt="WeChat group QR code" width="200">
## License
[Apache 2.0](LICENSE). Bundled third-party assets are listed in [`THIRD_PARTY_LICENSES.md`](THIRD_PARTY_LICENSES.md).
-1
View File
@@ -6,7 +6,6 @@ edition.workspace = true
license.workspace = true
[dependencies]
blake3.workspace = true
dunce.workspace = true
ide-core.workspace = true
zip.workspace = true
+6 -57
View File
@@ -2,49 +2,18 @@
use super::*;
use std::fs;
use std::hash::{Hash, Hasher};
use std::io::Read;
use std::time::SystemTime;
/// `(byte length, mtime, blake3 hex)`; the hash is recorded for files inside the project only.
type Stamp = (u64, SystemTime, Option<String>);
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct LaunchFingerprint {
version: u32,
parameters: String,
/// Per input: a [`Stamp`] for regular files, `None` for
/// directories and inputs that cannot be resolved. The content hash catches external
/// edits that preserve both length and mtime (timestamp-preserving tools, coarse
/// timestamp granularity), which `(length, mtime)` alone would miss. Only files inside
/// the project are hashed; toolchain inputs such as the JDK's `lib/modules` keep the stamp.
files: BTreeMap<PathBuf, Option<Stamp>>,
}
/// `blake3` hex of a file's bytes, streamed in chunks so memory stays bounded. An
/// unreadable file records a sentinel so one permission problem does not drop the whole
/// fingerprint (that would just force a normal rebuild).
fn content_hash(path: &Path) -> IdeResult<String> {
let mut file = match fs::File::open(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok("missing".into()),
Err(e) => return Err(e.into()),
};
let mut hasher = blake3::Hasher::new();
let mut buffer = [0; 64 * 1024];
loop {
let count = file.read(&mut buffer)?;
if count == 0 {
break;
}
hasher.update(&buffer[..count]);
}
Ok(hasher.finalize().to_hex().to_string())
files: BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
}
fn scan(
root: &Path,
path: &Path,
files: &mut BTreeMap<PathBuf, Option<Stamp>>,
files: &mut BTreeMap<PathBuf, Option<(u64, SystemTime)>>,
ancestors: &mut HashSet<PathBuf>,
cancel: &CancellationToken,
) -> IdeResult<()> {
@@ -67,12 +36,11 @@ fn scan(
// Directory mtimes change on irrelevant JDT writes. File paths detect additions/deletions.
files.insert(path.to_path_buf(), None);
for entry in fs::read_dir(path)? {
scan(root, &entry?.path(), files, ancestors, cancel)?;
scan(&entry?.path(), files, ancestors, cancel)?;
}
ancestors.remove(&canonical);
} else if meta.is_file() {
let hash = if path.starts_with(root) { Some(content_hash(path)?) } else { None };
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?, hash)));
files.insert(path.to_path_buf(), Some((meta.len(), meta.modified()?)));
} else {
return Err(IdeError::internal("unsupported launch input file"));
}
@@ -177,11 +145,11 @@ pub fn launch_fingerprint(
.map_err(|e| IdeError::internal(e.to_string()))?;
let mut files = BTreeMap::new();
for path in inputs {
scan(root, &path, &mut files, &mut HashSet::new(), cancel)?;
scan(&path, &mut files, &mut HashSet::new(), cancel)?;
}
// Store project inputs relative to the project, including each file's relative path.
let files = files.into_iter().map(|(p, stamp)| (p.strip_prefix(root).unwrap_or(&p).to_path_buf(), stamp)).collect();
Ok(Some(LaunchFingerprint { version: 2, parameters, files }))
Ok(Some(LaunchFingerprint { version: 1, parameters, files }))
}
#[cfg(test)]
@@ -263,25 +231,6 @@ mod tests {
}
}
/// An external tool that keeps the byte length and the mtime (timestamp-preserving
/// writes, coarse-granularity filesystems) still changes the content: the fingerprint
/// must differ via the content hash, not via `(length, mtime)`.
#[test]
fn external_edit_preserving_length_and_mtime_still_invalidates() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let source = root.join("lib/src/main/kotlin/Channel.kt");
let before = fingerprint(root, &model);
assert!(before.is_some());
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
// Same 5-byte length, different content.
write(root, "lib/src/main/kotlin/Channel.kt", "valve");
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let after = fingerprint(root, &model);
assert_ne!(before, after, "a same-length, same-mtime content change must invalidate the fingerprint");
}
#[test]
fn profile_options_jdk_goal_and_wrapper_invalidate_without_using_class_outputs() {
let dir = tempfile::tempdir().unwrap();
+1 -3
View File
@@ -440,9 +440,7 @@ pub fn load(root: &Path) -> Option<GradleModel> {
/// Loads exactly this build, without searching its child folders.
pub fn load_build(root: &Path, dir: &Path) -> Option<GradleModel> {
// Linked builds are saved with forward slashes; rebuild the path so the launcher
// and module folders use the platform's separators.
let dir: PathBuf = root.join(dir).components().collect();
let dir = root.join(dir);
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
return None;
}
-3
View File
@@ -252,8 +252,6 @@ pub struct SmartKeysSettings {
/// Reformat on paste: `none` | `indentBlock` | `indentEachLine`.
pub reformat_on_paste: String,
pub smart_indent: bool,
/// Enter › Insert documentation comment stub: Enter after `/**` closes the comment.
pub insert_doc_stub: bool,
/// Backspace › Unindent: `autoindent` (proper indent position) | `nearest` indent position | `none`.
pub backspace_unindent: String,
}
@@ -269,7 +267,6 @@ impl Default for SmartKeysSettings {
tab_out_of_pair: true,
reformat_on_paste: "indentEachLine".into(),
smart_indent: true,
insert_doc_stub: true,
backspace_unindent: "autoindent".into(),
}
}
-3
View File
@@ -229,9 +229,6 @@ pub enum DebugEvent {
thread_id: i64,
started: bool,
},
/// The program runs in a terminal the IDE gave it (`runInTerminal`): what is typed for it
/// in the debug console reaches its input.
ProgramInput,
BreakpointsChanged {
path: crate::IpcPath,
},
+6 -59
View File
@@ -1,5 +1,5 @@
//! Settings of remote work: SSH configurations, SFTP deployment and run
//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`.
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
//! targets. The fields hang off `Settings` in `settings.rs`.
use serde::{Deserialize, Serialize};
use specta::Type;
@@ -140,55 +140,22 @@ pub struct DeploymentMapping {
pub web: String,
}
/// Where a run target runs configurations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub enum RunTargetKind {
/// Another machine over SSH, the project synchronised to its folder.
#[default]
Ssh,
/// A new container of an image (`docker run --rm`), the project bind-mounted.
Docker,
/// A new container of a Compose service (`docker compose run --rm`).
Compose,
}
/// Build, Execution, Deployment › Run Targets: run configurations on another machine
/// or in a container.
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct RunTarget {
pub kind: RunTargetKind,
pub id: String,
pub name: String,
/// [`SshConfiguration::id`] (SSH).
/// [`SshConfiguration::id`].
pub ssh_config: String,
/// Remote folder the project is synchronised to and run from (SSH).
/// Remote folder the project is synchronised to and run from.
pub project_root: String,
/// Upload changed project files before each run (SSH).
/// Upload changed project files before each run.
pub sync_before_run: bool,
/// Interpreters on the target; empty = the one on the remote `PATH`.
pub python: String,
pub node: String,
pub java: String,
/// Id of a Docker connection of the Services tool window (Docker, Compose);
/// empty = the CLI's own daemon.
pub docker_connection: String,
/// Image to run (Docker).
pub image: String,
/// Compose files, relative to the project; empty = the project's default one (Compose).
pub compose_files: Vec<String>,
/// Service to run (Compose).
pub service: String,
/// Where the project is in the container; empty = `/workspaces/<folder>` (Docker),
/// the service's bind mount of the project (Compose).
pub container_root: String,
/// Published ports, as `docker run --publish` takes them (`8000:8000`).
pub ports: Vec<String>,
/// More options for `docker run` / `docker compose run`, shell-like quoted.
pub run_options: String,
/// Run as the image's user instead of the project folder's owner (Linux).
pub image_user: bool,
}
#[cfg(test)]
@@ -209,24 +176,4 @@ mod deployment_tests {
);
}
}
#[test]
fn run_target_kind_defaults_to_ssh_and_round_trips() {
let legacy: RunTarget =
serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap();
assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh"));
let docker = RunTarget {
kind: RunTargetKind::Compose,
docker_connection: "local".into(),
compose_files: vec!["compose.dev.yaml".into()],
service: "web".into(),
ports: vec!["8000:8000".into()],
image_user: true,
..legacy
};
let json = serde_json::to_value(&docker).unwrap();
assert_eq!(json["kind"], "compose");
assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"]));
assert_eq!(serde_json::from_value::<RunTarget>(json).unwrap(), docker);
}
}
+3 -36
View File
@@ -601,7 +601,7 @@ impl Default for FileSettings {
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct LanguagesSettings {
pub python: PythonSettings,
@@ -612,34 +612,11 @@ pub struct LanguagesSettings {
pub typescript: TypeScriptSettings,
pub prettier: ExternalFormatterSettings,
pub oxfmt: ExternalFormatterSettings,
pub ruff: ExternalFormatterSettings,
pub java: JavaSettings,
pub ruby: RubySettings,
pub markdown: MarkdownSettings,
}
impl Default for LanguagesSettings {
fn default() -> Self {
Self {
python: PythonSettings::default(),
node: NodeSettings::default(),
php: PhpSettings::default(),
rust: RustSettings::default(),
go: GoSettings::default(),
typescript: TypeScriptSettings::default(),
prettier: ExternalFormatterSettings::default(),
oxfmt: ExternalFormatterSettings::default(),
ruff: ExternalFormatterSettings {
languages: vec!["python".into()],
..ExternalFormatterSettings::default()
},
java: JavaSettings::default(),
ruby: RubySettings::default(),
markdown: MarkdownSettings::default(),
}
}
}
/// Languages & Frameworks › Markdown. Like IDEA's, it is application-level only.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
@@ -653,7 +630,6 @@ impl LanguagesSettings {
match tool {
ExternalFormatter::Prettier => &self.prettier,
ExternalFormatter::Oxfmt => &self.oxfmt,
ExternalFormatter::Ruff => &self.ruff,
}
}
}
@@ -664,7 +640,6 @@ impl LanguagesSettings {
pub enum ExternalFormatter {
Prettier,
Oxfmt,
Ruff,
}
impl ExternalFormatter {
@@ -673,19 +648,16 @@ impl ExternalFormatter {
match self {
Self::Prettier => "prettier",
Self::Oxfmt => "oxfmt",
Self::Ruff => "ruff",
}
}
}
/// An external formatter (Prettier, oxfmt, Ruff) and the languages it formats.
/// An external formatter (Prettier, oxfmt) and the languages it formats.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
#[serde(default, rename_all = "camelCase")]
pub struct ExternalFormatterSettings {
/// Empty path: project node_modules (Prettier/oxfmt), managed installation, then PATH.
/// Empty path: project node_modules, managed installation, then PATH.
pub path: String,
/// Ruff only: directory containing its configuration, or a configuration file.
pub configuration_directory: String,
pub languages: Vec<String>,
/// Prefer this formatter even when a language server offers formatting.
pub prefer: bool,
@@ -696,7 +668,6 @@ impl Default for ExternalFormatterSettings {
fn default() -> Self {
Self {
path: String::new(),
configuration_directory: String::new(),
languages: [
"markdown",
"yaml",
@@ -751,10 +722,6 @@ pub struct PhpSettings {
/// Intelephense stub sets loaded on top of its defaults and the `ext-*` entries of
/// `composer.json` (`wordpress`).
pub stubs: Vec<String>,
/// PHPStan configuration file for checking saved files; empty = the `phpstan.neon`,
/// `phpstan.neon.dist` or `phpstan.dist.neon` PHPStan finds in the project root. Absolute,
/// `~/`, or relative to the project.
pub phpstan_config: String,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
+1 -16
View File
@@ -30,21 +30,6 @@ pub fn search_path() -> OsString {
std::env::join_paths(dirs).unwrap_or_default()
}
/// Ruby code printing where `gem install` puts executables: the Ruby's own `Gem.bindir`, which
/// Homebrew's Ruby keeps off `PATH`, and the `--user-install` one.
pub const RUBY_GEM_BIN_SCRIPT: &str = "puts Gem.bindir, File.join(Gem.user_dir, 'bin')";
/// The directories [`RUBY_GEM_BIN_SCRIPT`] printed that exist.
pub fn gem_bin_dirs(output: &str) -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
for dir in output.lines().map(str::trim).map(PathBuf::from) {
if dir.is_absolute() && dir.is_dir() && !dirs.contains(&dir) {
dirs.push(dir);
}
}
dirs
}
/// Resolves a command on the shared path, or an explicit path relative to `root`.
pub fn find_command(command: &str, search: &OsStr, root: &Path) -> Option<PathBuf> {
let path = Path::new(command);
@@ -143,7 +128,7 @@ fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
}
/// `v22.10.1` as `[22, 10, 1]`: v22 is newer than v9, v20.10 newer than v20.9.
pub fn version_key(name: &str) -> Vec<u64> {
fn version_key(name: &str) -> Vec<u64> {
name.trim_start_matches('v').split('.').map(|n| n.parse().unwrap_or(0)).collect()
}
+3 -28
View File
@@ -1,8 +1,8 @@
//! "Import a report collected in CI from disk": the format is sniffed from the
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
//! content (lcov, Go cover profile, JaCoCo XML) and paths recorded on another
//! machine are mapped into the project by their longest existing suffix.
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
use ide_core::{IdeError, IdeResult};
use std::path::{Component, Path, PathBuf};
@@ -11,7 +11,6 @@ pub(crate) enum Format {
Lcov,
GoProfile,
Jacoco,
SimpleCov,
}
pub(crate) fn detect(text: &str) -> Option<Format> {
@@ -19,9 +18,6 @@ pub(crate) fn detect(text: &str) -> Option<Format> {
if head.starts_with('<') {
return head.contains("<report").then_some(Format::Jacoco);
}
if head.starts_with('{') {
return head.contains("\"coverage\"").then_some(Format::SimpleCov);
}
let first = head.lines().next().unwrap_or_default();
if first.starts_with("mode:") {
return Some(Format::GoProfile);
@@ -34,7 +30,6 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
let text = std::fs::read_to_string(path)?;
let mut suite = match detect(&text) {
Some(Format::Lcov) => lcov::parse(&text, root).finish("lcov"),
Some(Format::SimpleCov) => simplecov::parse(&text, root)?.finish("SimpleCov"),
Some(Format::GoProfile) => {
let (module, module_dir) = go_module_for(root, &text);
goprofile::parse(&text, &module, &module_dir).finish("go test -cover")
@@ -55,7 +50,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
}
None => {
return Err(IdeError::invalid(format!(
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
path.display()
)));
}
@@ -130,29 +125,9 @@ mod tests {
Some(Format::Jacoco)
);
assert_eq!(detect("<?xml version=\"1.0\"?><coverage/>"), None);
assert_eq!(detect("{\"RSpec\": {\"coverage\": {}}}"), Some(Format::SimpleCov));
assert_eq!(detect("{\"name\": \"package.json\"}"), None);
assert_eq!(detect("hello"), None);
}
#[test]
fn imports_ci_simplecov_with_foreign_paths() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().join("app");
std::fs::create_dir_all(root.join("lib")).unwrap();
std::fs::write(root.join("lib/a.rb"), "").unwrap();
let report = tmp.path().join(".resultset.json");
std::fs::write(
&report,
r#"{"RSpec":{"coverage":{"/home/runner/work/app/app/lib/a.rb":{"lines":[null,1,0]}}}}"#,
)
.unwrap();
let suite = import(&report, &root).unwrap();
assert_eq!(suite.runner, "SimpleCov");
assert_eq!(suite.files[0].path.as_ref() as &Path, root.join("lib/a.rb"));
assert_eq!((suite.files[0].lines_total, suite.files[0].lines_covered), (2, 1));
}
#[test]
fn imports_ci_lcov_with_foreign_paths() {
let tmp = tempfile::tempdir().unwrap();
-8
View File
@@ -10,7 +10,6 @@
//! | JS / TS (vitest, jest scripts) | the runner's `--coverage` | lcov |
//! | Node.js scripts | `c8` | lcov |
//! | Python (pytest, scripts) | coverage.py (`coverage run`, then `coverage lcov`) | lcov |
//! | Ruby (rspec, minitest, rake, scripts) | SimpleCov (`coverage/.resultset.json`) | SimpleCov resultset |
//! | Go (`go test`) | `-coverprofile` | Go cover profile |
//! | Maven / Gradle tests | JaCoCo (plugin goals / an init script) | JaCoCo XML |
//!
@@ -21,7 +20,6 @@ mod import;
mod jacoco;
mod lcov;
mod plan;
mod simplecov;
pub use import::import;
pub use plan::prepare;
@@ -85,8 +83,6 @@ pub enum CoverageReportSpec {
Lcov { runner: String, path: IpcPath, base: IpcPath },
/// `go test -coverprofile`; import paths under `module` map to `module_dir`.
GoProfile { path: IpcPath, module: String, module_dir: IpcPath },
/// SimpleCov's `.resultset.json`; relative paths resolve against `base`.
SimpleCov { path: IpcPath, base: IpcPath },
/// Every JaCoCo XML report (Maven `target/site/jacoco`, Gradle `build/reports/jacoco`) under `dir`.
Jacoco { dir: IpcPath },
/// The tool's raw data is exported to lcov by `argv` first (coverage.py: `… -m coverage lcov -o <lcov>`).
@@ -189,10 +185,6 @@ pub fn collect(spec: &CoverageReportSpec) -> IdeResult<CoverageSuite> {
let text = read_report(path)?;
Ok(lcov::parse(&text, base).finish(runner))
}
CoverageReportSpec::SimpleCov { path, base } => {
let text = read_report(path)?;
Ok(simplecov::parse(&text, base)?.finish("SimpleCov"))
}
CoverageReportSpec::GoProfile { path, module, module_dir } => {
let text = read_report(path)?;
Ok(goprofile::parse(&text, module, module_dir).finish("go test -cover"))
-128
View File
@@ -67,7 +67,6 @@ pub fn prepare(root: &Path, config: &RunConfig, out: &Path) -> CoveragePrepared
"gradle" | "gradlew" => gradle(config, &dir, out),
"npm" | "pnpm" | "yarn" | "bun" => npm(root, config, &dir, out),
"node" | "npx" | "tsx" | "ts-node" => c8(root, config, &dir, out),
"ruby" | "bundle" | "rake" | "rspec" | "rails" => ruby(root, config, &dir, out),
"deno" => unsupported("Coverage of Deno tasks is not supported yet."),
"java" => unsupported(
"Coverage of Java applications needs the JaCoCo agent; run a Maven or Gradle test configuration with coverage.",
@@ -235,65 +234,6 @@ fn c8(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepar
ready("c8", config, path(&c8), args, report)
}
/// Loaded first (`ide_run::ruby_preload`) when the project never starts SimpleCov itself.
const SIMPLECOV_PRELOAD: &str = r#"# Written by OpenIDEA for Run with Coverage.
begin
require "simplecov"
rescue LoadError
# `bundle exec` is a Ruby process too and loads this before Bundler puts the project's gems on
# the load path; the Ruby it starts loads bundler/setup first and finds SimpleCov.
return
end
SimpleCov.coverage_dir(ENV.fetch("OPENIDEA_COVERAGE_DIR"))
SimpleCov.start do
add_filter %r{^/(test|spec|features)/}
enable_coverage :branch
end
"#;
/// Files where a project conventionally starts SimpleCov.
const SIMPLECOV_STARTERS: &[&str] =
&[".simplecov", "spec/spec_helper.rb", "spec/rails_helper.rb", "test/test_helper.rb", "test/minitest_helper.rb"];
fn ruby(root: &Path, config: &RunConfig, dir: &Path, out: &Path) -> CoveragePrepared {
let bundled = [dir, root].iter().any(|d| d.join("Gemfile").is_file());
let mentions = |file: &str| {
[dir, root].iter().any(|d| std::fs::read_to_string(d.join(file)).is_ok_and(|t| t.contains("simplecov")))
};
if bundled && !mentions("Gemfile.lock") && !mentions("Gemfile") {
return missing(
"simplecov",
"Ruby coverage needs the simplecov gem in the project's Gemfile.",
Some((vec![s("bundle"), s("add"), s("simplecov"), s("--group"), s("test")], dir)),
);
}
let starts = |d: &Path| {
SIMPLECOV_STARTERS.iter().any(|f| {
std::fs::read_to_string(d.join(f))
.is_ok_and(|t| t.contains("SimpleCov.start") || f.starts_with(".simplecov"))
})
};
let mut config = config.clone();
let resultset = if [dir, root].iter().any(|d| starts(d)) {
// The project's own configuration decides where the report goes (default `coverage/`).
// Drop the previous run's data: SimpleCov merges into it.
let results = dir.join("coverage");
let _ = std::fs::remove_file(results.join(".resultset.json"));
results.join(".resultset.json")
} else {
let preload = out.join("openidea_simplecov.rb");
if let Err(e) = std::fs::write(&preload, SIMPLECOV_PRELOAD) {
return unsupported(format!("Cannot write the SimpleCov preload: {e}"));
}
config.env.insert(s("OPENIDEA_COVERAGE_DIR"), path(out));
// `out` lives under the IDE's data directory, which may have spaces (RUBYOPT cannot take them).
ide_run::ruby_preload::add(&mut config.env, out, "openidea_simplecov", true);
out.join(".resultset.json")
};
let report = CoverageReportSpec::SimpleCov { path: resultset.into(), base: dir.to_path_buf().into() };
ready("SimpleCov", &config, config.command.clone(), config.args.clone(), report)
}
fn python(config: &RunConfig, dir: &Path, out: &Path, py: wrap::PythonCommand) -> CoveragePrepared {
if py.target.is_empty() {
return unsupported("Coverage needs a Python script or module to run.");
@@ -458,74 +398,6 @@ mod tests {
));
}
/// `bundle exec` loads the preload before Bundler sets up the project's gems: without SimpleCov
/// on the load path it must step aside instead of failing the launcher.
#[test]
fn simplecov_preload_tolerates_a_ruby_without_the_gem() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out dir");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(out.join("openidea_simplecov.rb"), SIMPLECOV_PRELOAD).unwrap();
let status = std::process::Command::new("ruby")
.args(["-e", "exit 0"])
.env("RUBYLIB", &out)
.env("RUBYOPT", "-ropenidea_simplecov")
.env("OPENIDEA_COVERAGE_DIR", &out)
.env("GEM_HOME", dir.path().join("empty gems"))
.env("GEM_PATH", dir.path().join("empty gems"))
.status();
// No Ruby on this machine: nothing to check.
let Ok(status) = status else { return };
assert!(status.success());
}
#[test]
fn ruby_runs_get_simplecov() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
// The IDE's data directory has spaces on macOS (`Application Support`).
let out = root.join("out dir");
std::fs::create_dir_all(&out).unwrap();
std::fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
let config = cfg("rspec", "bundle", &["exec", "rspec"]);
// Bundler project without the gem: install hint.
match prepare(root, &config, &out) {
CoveragePrepared::MissingTool { tool, install: Some(i), .. } => {
assert_eq!(tool, "simplecov");
assert_eq!(i.args, ["add", "simplecov", "--group", "test"]);
}
other => panic!("{other:?}"),
}
// Gem present, never started: preloaded through RUBYOPT, report in `out`.
std::fs::write(root.join("Gemfile"), "gem 'simplecov'\n").unwrap();
let p = prepare(root, &config, &out);
let (l, report) = launch(&p);
assert_eq!(
(l.command.as_str(), l.args.as_slice()),
("bundle", ["exec".to_owned(), "rspec".to_owned()].as_slice())
);
assert!(l.env["RUBYOPT"].contains("-ropenidea_simplecov"));
assert!(std::env::split_paths(&l.env["RUBYLIB"]).any(|d| d == out));
assert_eq!(l.env["OPENIDEA_COVERAGE_DIR"], out.to_string_lossy());
assert!(out.join("openidea_simplecov.rb").is_file());
assert!(
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).starts_with(&out))
);
// Started by the project: untouched environment, stale data dropped, report in `coverage/`.
std::fs::create_dir_all(root.join("test")).unwrap();
std::fs::create_dir_all(root.join("coverage")).unwrap();
std::fs::write(root.join("coverage/.resultset.json"), "{}").unwrap();
std::fs::write(root.join("test/test_helper.rb"), "require 'simplecov'\nSimpleCov.start\n").unwrap();
let p = prepare(root, &cfg("ruby", "ruby", &["-Ilib", "test/a_test.rb"]), &out);
let (l, report) = launch(&p);
assert!(!l.env.contains_key("RUBYOPT"));
assert_eq!(l.args, ["-Ilib", "test/a_test.rb"]);
assert!(!root.join("coverage/.resultset.json").exists());
assert!(
matches!(report, CoverageReportSpec::SimpleCov { path, .. } if (path.as_ref() as &Path).ends_with("coverage/.resultset.json"))
);
}
#[test]
fn unknown_commands_are_unsupported() {
let dir = tempfile::tempdir().unwrap();
-74
View File
@@ -1,74 +0,0 @@
//! SimpleCov's `coverage/.resultset.json`:
//!
//! ```json
//! { "RSpec": { "timestamp": 1, "coverage": {
//! "/abs/lib/a.rb": { "lines": [null, 1, 0],
//! "branches": { "[:if, 0, 3, 4, 3, 20]": { "[:then, 1, 4, 6, 4, 9]": 1, "[:else, 2, 6, 6, 6, 9]": 0 } } },
//! "/abs/lib/legacy.rb": [null, 1] } } }
//! ```
//!
//! `lines[i]` is line `i + 1` (`null` = not relevant), so the index is already 0-based.
//! A branch belongs to the line its condition starts on (third number of its key).
use crate::Accumulator;
use ide_core::{IdeError, IdeResult};
use serde_json::Value;
use std::path::Path;
/// Start line (1-based) of a `"[:if, 0, 3, 4, 3, 20]"` branch key.
fn start_line(key: &str) -> Option<u32> {
key.trim_matches(['[', ']']).split(',').nth(2)?.trim().parse().ok()
}
pub(crate) fn parse(text: &str, base: &Path) -> IdeResult<Accumulator> {
let json: Value = serde_json::from_str(text).map_err(|e| IdeError::invalid(format!("SimpleCov resultset: {e}")))?;
let mut acc = Accumulator::default();
// One entry per test command ("RSpec", "Minitest", …); their coverage is merged.
for suite in json.as_object().into_iter().flat_map(|o| o.values()) {
let Some(files) = suite.get("coverage").and_then(Value::as_object) else { continue };
for (name, data) in files {
let p = Path::new(name);
let path = if p.is_absolute() { p.to_path_buf() } else { base.join(p) };
acc.file(&path);
let lines = data.get("lines").or(Some(data)).and_then(Value::as_array);
for (i, hits) in lines.into_iter().flatten().enumerate() {
if let Some(hits) = hits.as_u64() {
acc.line(&path, i as u32, hits);
}
}
for (cond, branches) in data.get("branches").and_then(Value::as_object).into_iter().flatten() {
let Some(line) = start_line(cond) else { continue };
for (branch, hits) in branches.as_object().into_iter().flatten() {
acc.has_branches = true;
acc.branch(&path, line.saturating_sub(1), format!("{cond}/{branch}"), hits.as_u64() > Some(0));
}
}
}
}
Ok(acc)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reads_lines_branches_and_merges_suites() {
let text = r#"{
"RSpec": {"coverage": {"/p/lib/a.rb": {"lines": [null, 1, 0],
"branches": {"[:if, 0, 2, 4, 2, 20]": {"[:then, 1, 2, 6, 2, 9]": 1, "[:else, 2, 2, 6, 2, 9]": 0}}}}},
"Minitest": {"coverage": {"/p/lib/a.rb": {"lines": [null, 2, 3]}, "lib/old.rb": [1, null]}}
}"#;
let suite = parse(text, Path::new("/p")).unwrap().finish("SimpleCov");
assert_eq!(suite.files.len(), 2);
let a = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("a.rb")).unwrap();
assert_eq!(a.lines.iter().map(|l| (l.line, l.hits)).collect::<Vec<_>>(), [(1, 3), (2, 3)]);
assert_eq!((a.branches_total, a.branches_covered), (2, 1));
assert_eq!(a.lines[0].branches_total, 2);
let old = suite.files.iter().find(|f| (f.path.as_ref() as &Path).ends_with("lib/old.rb")).unwrap();
assert_eq!(old.path.as_ref() as &Path, Path::new("/p/lib/old.rb"));
assert_eq!(old.lines[0].hits, 1);
assert!(suite.has_branches);
assert!(parse("nope", Path::new("/p")).is_err());
}
}
-1
View File
@@ -16,7 +16,6 @@ specta.workspace = true
tokio = { workspace = true, features = ["net"] }
parking_lot.workspace = true
memchr = "2"
portable-pty.workspace = true
tracing.workspace = true
[dev-dependencies]
+5 -5
View File
@@ -64,13 +64,13 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"codelldb",
&["--port", PORT_PLACEHOLDER],
"tcp",
"Install CodeLLDB in Settings › Languages & Frameworks › Language Servers",
"Install the CodeLLDB adapter (https://github.com/vadimcn/codelldb/releases) and put `codelldb` on PATH",
),
"kotlin-debug-adapter" => cfg(
"kotlin-debug-adapter",
&[],
"stdio",
"Install Kotlin Debug Adapter in Settings › Languages & Frameworks › Language Servers",
"Install the Kotlin Debug Adapter from Settings > Language Servers",
),
// Windows installs `python.exe`; `python3.exe` there is at most a Store alias.
"debugpy" => cfg(
@@ -84,7 +84,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
// Without a host it listens on `localhost`, which may resolve to ::1 only.
&[PORT_PLACEHOLDER, "127.0.0.1"],
"tcp",
"Install JavaScript Debugger (vscode-js-debug) in Settings › Languages & Frameworks › Language Servers",
"Install vscode-js-debug's DAP server (js-debug-dap-*.tar.gz from https://github.com/microsoft/vscode-js-debug/releases) as `js-debug-adapter`",
),
"delve" => cfg(
"dlv",
@@ -96,7 +96,7 @@ fn builtin(adapter: &str) -> Option<DebuggerConfig> {
"netcoredbg",
&["--interpreter=vscode"],
"stdio",
"Install netcoredbg (.NET debugger) in Settings › Languages & Frameworks › Language Servers",
"Install Samsung netcoredbg (https://github.com/Samsung/netcoredbg/releases)",
),
"lldb-dap" => {
cfg("lldb-dap", &[], "stdio", "Install LLVM's lldb-dap (brew install llvm; on macOS `xcrun -f lldb-dap`)")
@@ -668,7 +668,7 @@ mod tests {
assert!(find_binary("definitely-not-a-real-binary-openidea", &spec.cwd).is_none());
let err = missing(&spec);
assert_eq!(err.kind, ide_core::error::ErrorKind::Unavailable);
assert!(err.message.contains("Settings › Languages & Frameworks › Language Servers"));
assert!(err.message.contains("netcoredbg/releases"));
}
#[tokio::test]
-10
View File
@@ -1266,16 +1266,6 @@ impl DebugManager {
Ok(())
}
/// Types `text` into the terminal the session's program runs in (see [`ide_core::event::DebugEvent::ProgramInput`]).
pub fn send_input(&self, s: DebugSessionId, text: &str) -> IdeResult<()> {
let session = self.session(s)?;
let mut program = session.program.lock();
let program = program
.as_mut()
.ok_or_else(|| IdeError::invalid("The program doesn't read input from the debug console"))?;
program.write(text).map_err(|e| IdeError::internal(format!("cannot write to the program: {e}")))
}
/// Flutter's DAP adapter: injects the changed sources into the running app.
/// Not part of the DAP standard; adapters that do not know the request reject it.
pub async fn hot_reload(&self, s: DebugSessionId) -> IdeResult<()> {
+19 -86
View File
@@ -43,8 +43,6 @@ pub(crate) struct Session {
pub adapter_process: Mutex<Option<Child>>,
/// Processes started for `runInTerminal`.
pub debuggees: Mutex<Vec<Child>>,
/// The terminal a `runInTerminal` program runs in: typed input goes to it.
pub program: Mutex<Option<ProgramTerminal>>,
/// Set once `configurationDone` was sent; breakpoint updates go out live after that.
pub configured: AtomicBool,
pub initialized: watch::Sender<bool>,
@@ -92,7 +90,6 @@ impl Session {
caps: Mutex::new(Value::Null),
adapter_process: Mutex::new(child),
debuggees: Mutex::new(Vec::new()),
program: Mutex::new(None),
configured: AtomicBool::new(false),
initialized: watch::channel(false).0,
terminated: AtomicBool::new(false),
@@ -269,9 +266,6 @@ impl Session {
for mut c in std::mem::take(&mut *self.debuggees.lock()) {
let _ = c.start_kill();
}
if let Some(mut program) = self.program.lock().take() {
let _ = program.child.kill();
}
}
}
@@ -457,25 +451,8 @@ fn on_reverse_request(mgr: &Arc<DebugManager>, s: &Arc<Session>, seq: i64, comma
}
}
/// A `runInTerminal` program and the pseudo-terminal it runs in.
pub(crate) struct ProgramTerminal {
writer: Box<dyn std::io::Write + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
_master: Box<dyn portable_pty::MasterPty + Send>,
}
impl ProgramTerminal {
pub(crate) fn write(&mut self, text: &str) -> std::io::Result<()> {
self.writer.write_all(text.as_bytes())?;
self.writer.flush()
}
}
/// `runInTerminal`: starts the debuggee on a pseudo-terminal, as an IDE terminal would (CodeLLDB
/// starts a launcher that gives the program its terminal, and the program reads input only from
/// a terminal), forwards its output to the console, and keeps the terminal for typed input.
/// `runInTerminal`: spawns the debuggee directly and forwards its output to the console.
pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, String> {
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
let argv: Vec<String> = args
.get("args")
.and_then(Value::as_array)
@@ -484,25 +461,16 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
let Some((program, rest)) = argv.split_first() else {
return Err("runInTerminal without args".into());
};
let pair = native_pty_system()
.openpty(PtySize { rows: 24, cols: 200, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("cannot open a terminal for {program}: {e}"))?;
let mut cmd = CommandBuilder::new(program);
// CodeLLDB's launcher clears the terminal first; the console keeps the session's earlier output.
cmd.args(rest.iter().filter(|a| *a != "--clear-screen"));
for (k, v) in ide_core::process::host_env_fixes() {
match v {
Some(v) => cmd.env(k, v),
None => cmd.env_remove(k),
}
}
for (k, v) in ide_core::process::shell_env() {
cmd.env(k, v);
}
cmd.env("TERM", "xterm-256color");
match args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
Some(cwd) => cmd.cwd(cwd),
None => cmd.cwd(&s.spec.cwd),
let mut cmd = tokio::process::Command::new(program);
ide_core::process::prepare(cmd.as_std_mut());
cmd.args(rest)
.envs(ide_core::process::shell_env())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
if let Some(cwd) = args.get("cwd").and_then(Value::as_str).filter(|c| !c.is_empty()) {
cmd.current_dir(cwd);
}
if let Some(env) = args.get("env").and_then(Value::as_object) {
for (k, v) in env {
@@ -512,53 +480,18 @@ pub(crate) fn run_in_terminal(s: &Arc<Session>, args: &Value) -> Result<Value, S
};
}
}
let child = pair.slave.spawn_command(cmd).map_err(|e| format!("failed to start {program}: {e}"))?;
drop(pair.slave);
let reader = pair.master.try_clone_reader().map_err(|e| format!("terminal of {program}: {e}"))?;
let writer = pair.master.take_writer().map_err(|e| format!("terminal of {program}: {e}"))?;
let pid = child.process_id();
forward_terminal_output(s, reader);
if let Some(mut previous) = s.program.lock().replace(ProgramTerminal { writer, child, _master: pair.master }) {
let _ = previous.child.kill();
let mut child = cmd.spawn().map_err(|e| format!("failed to start {program}: {e}"))?;
let pid = child.id();
if let Some(out) = child.stdout.take() {
forward_output(s, out, "stdout");
}
s.owner.sink.emit(IdeEvent::Debug { session: s.id, event: DebugEvent::ProgramInput });
if let Some(err) = child.stderr.take() {
forward_output(s, err, "stderr");
}
s.debuggees.lock().push(child);
Ok(json!({ "processId": pid }))
}
/// The program's terminal output, as stdout lines (`\r\n` as `\n`; colours are kept).
fn forward_terminal_output(s: &Session, mut reader: Box<dyn std::io::Read + Send>) {
let session = s.id;
let sink = s.owner.sink.clone();
let _ = std::thread::Builder::new().name(format!("openidea-debuggee-{}", session.0)).spawn(move || {
let mut buf = vec![0u8; 8192];
let mut pending: Vec<u8> = Vec::new();
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => pending.extend_from_slice(&buf[..n]),
}
// Keep incomplete UTF-8 sequences, and a `\r` that may start a `\r\n`, for the next chunk.
let mut valid = match std::str::from_utf8(&pending) {
Ok(_) => pending.len(),
Err(e) if e.error_len().is_none() => e.valid_up_to(),
Err(_) => pending.len(),
};
if valid > 0 && pending[valid - 1] == b'\r' {
valid -= 1;
}
let output = String::from_utf8_lossy(&pending[..valid]).replace("\r\n", "\n");
pending.drain(..valid);
if !output.is_empty() {
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
}
if !pending.is_empty() {
let output = String::from_utf8_lossy(&pending).replace("\r\n", "\n");
sink.emit(IdeEvent::Debug { session, event: DebugEvent::Output { category: "stdout".into(), output } });
}
});
}
fn forward_output<R: AsyncRead + Unpin + Send + 'static>(s: &Session, mut stream: R, category: &'static str) {
let session = s.id;
let sink = s.owner.sink.clone();
-34
View File
@@ -468,40 +468,6 @@ async fn run_in_terminal_spawns_debuggee() {
assert!(response["body"]["processId"].as_u64().unwrap() > 0);
}
#[cfg(unix)]
#[tokio::test]
async fn run_in_terminal_program_reads_typed_input() {
let h = harness(Script {
// CodeLLDB's launcher asks to clear the terminal; the console keeps its output.
run_in_terminal: Some(vec![
"sh".into(),
"-c".into(),
"[ -t 0 ] && echo 'name?' && read name && echo \"hello $name\"".into(),
"--clear-screen".into(),
]),
..Script::default()
});
let session = h.start(config()).await.unwrap();
h.wait_event(|e| matches!(e, DebugEvent::ProgramInput)).await;
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("name?"))).await;
h.manager.send_input(session, "Bob\n").unwrap();
h.wait_event(|e| matches!(e, DebugEvent::Output { output, .. } if output.contains("hello Bob"))).await;
let output: String = h
.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Debug { event: DebugEvent::Output { category, output }, .. } if category == "stdout" => {
Some(output.clone())
}
_ => None,
})
.collect();
assert!(!output.contains('\r'), "{output:?}");
h.manager.stop(session).await.unwrap();
assert!(h.manager.send_input(session, "x\n").is_err());
}
#[test]
fn launch_arguments_merge_and_defaults() {
let mut c = config();
-140
View File
@@ -1,140 +0,0 @@
//! Drives php-debug (vscode-php-debug) the way a PHP Remote Debug (Xdebug)
//! configuration does: a launch without a program makes it listen, and every PHP
//! run that Xdebug connects stops at the breakpoint set in the local copy of the
//! file, through `pathMappings`, until the session is stopped. A second listener on
//! the same port fails to start and names the port.
//!
//! Ignored by default (needs PHP with Xdebug 3, Node.js and php-debug). Run with
//! `PHP_DEBUG_ADAPTER=<php-debug-adapter> cargo test -p ide-dap --test php_debug -- --ignored --nocapture`
//! (`PHP_BIN` picks the PHP executable, default `php`).
use ide_core::event::DebugEvent;
use ide_core::ipc_path::to_ipc;
use ide_core::{IdeEvent, SharedSink};
use ide_dap::*;
use ide_language::LanguageRegistry;
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
const SCRIPT: &str = "<?php\n$greeting = \"hello\";\n$n = 40 + 2;\necho \"$greeting $n\\n\";\n";
/// 0-based line of `$n = 40 + 2;`.
const BREAK_LINE: u32 = 2;
async fn wait_for(events: &Mutex<Vec<IdeEvent>>, timeout: Duration, pred: impl Fn(&DebugEvent) -> bool) -> DebugEvent {
let start = Instant::now();
loop {
if let Some(e) = events.lock().iter().find_map(|e| match e {
IdeEvent::Debug { event, .. } if pred(event) => Some(event.clone()),
_ => None,
}) {
return e;
}
assert!(start.elapsed() < timeout, "timed out; events: {:#?}", events.lock());
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
fn listen_config(port: u16, served: &Path, local: &Path) -> LaunchConfig {
// As remote.ts builds it for Remote root = `served`, Local root = `local`.
let mapping = serde_json::Map::from_iter([(to_ipc(served), to_ipc(local).into())]);
LaunchConfig {
name: "PHP Remote".into(),
adapter: "php-debug".into(),
request: "launch".into(),
program: None,
args: vec![],
cwd: Some(local.to_path_buf().into()),
env: BTreeMap::new(),
extra_json: Some(serde_json::json!({ "port": port, "pathMappings": mapping }).to_string()),
}
}
#[tokio::test(flavor = "multi_thread")]
#[ignore]
async fn listens_for_xdebug_until_stopped_with_path_mappings() {
let Ok(adapter) = std::env::var("PHP_DEBUG_ADAPTER") else {
eprintln!("PHP_DEBUG_ADAPTER not set; skipping");
return;
};
let php = std::env::var("PHP_BIN").unwrap_or_else(|_| "php".into());
let dir = tempfile::tempdir().unwrap();
// The project here, and the copy PHP runs from (a container's /var/www/html).
let (local, served) = (dir.path().join("local"), dir.path().join("served"));
for d in [&local, &served] {
std::fs::create_dir_all(d).unwrap();
std::fs::write(d.join("index.php"), SCRIPT).unwrap();
}
// Resolved like the project root the UI sends (`/private/var` on macOS, no `\\?\` on Windows).
let resolved = |p: &Path| PathBuf::from(to_ipc(&std::fs::canonicalize(p).unwrap()));
let (local, served) = (resolved(&local), resolved(&served));
let port = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap().local_addr().unwrap().port();
let manager = DebugManager::new(Arc::new(LanguageRegistry::load(None).unwrap()));
let events: Arc<Mutex<Vec<IdeEvent>>> = Arc::default();
let sink: SharedSink = {
let events = events.clone();
Arc::new(move |e: IdeEvent| events.lock().push(e))
};
let local_script = local.join("index.php");
manager
.set_breakpoints(
local_script.clone(),
vec![Breakpoint {
line: BREAK_LINE,
condition: None,
hit_condition: None,
log_message: None,
enabled: true,
}],
)
.await
.unwrap();
let id = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink.clone()), |_| {
Some(adapter.clone())
})
.await
.unwrap_or_else(|e| panic!("start: {e:?}"));
// Requests in a row each stop; the session keeps listening between them.
for request in 1..=2 {
events.lock().clear();
let run = std::process::Command::new(&php)
.args([
"-dxdebug.mode=debug",
"-dxdebug.start_with_request=yes",
"-dxdebug.client_host=127.0.0.1",
&format!("-dxdebug.client_port={port}"),
"index.php",
])
.current_dir(&served)
.stdout(std::process::Stdio::piped())
.spawn()
.unwrap();
let stopped = wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Stopped { .. })).await;
let DebugEvent::Stopped { thread_id, .. } = stopped else { unreachable!() };
let frames = manager.stack_trace(id, thread_id).await.unwrap();
assert_eq!(frames[0].line, BREAK_LINE, "request {request}");
let top = frames[0].path.as_deref().and_then(|p| p.canonicalize().ok());
assert_eq!(top, local_script.canonicalize().ok(), "request {request}");
manager.resume(id, thread_id).await.unwrap();
wait_for(&events, Duration::from_secs(30), |e| matches!(e, DebugEvent::Thread { started: false, .. })).await;
let out = run.wait_with_output().unwrap();
assert_eq!(String::from_utf8_lossy(&out.stdout), "hello 42\n", "request {request}");
assert!(manager.sessions(None).iter().any(|s| s.id == id), "still listening after request {request}");
}
// Another listener on the port (another IDE): the start fails and says why.
let err = manager
.start_with(listen_config(port, &served, &local), SessionOwner::new("main", sink), |_| Some(adapter.clone()))
.await
.expect_err("a second listener on the same port");
assert!(err.message.contains("EADDRINUSE") && err.message.ends_with(&format!(":{port}")), "{err:?}");
assert_eq!(manager.sessions(None).len(), 1);
manager.stop(id).await.unwrap();
assert!(manager.sessions(None).is_empty());
}
-86
View File
@@ -440,89 +440,3 @@ pub fn compose_op(
});
run_streaming(ep, None, &args, cancel, on_output)
}
/// Whether `ep` is a daemon on this machine, where a bind mount of the project
/// is this machine's folder. The CLI's default daemon and a context are looked
/// up (`DOCKER_HOST`, else the context's endpoint).
pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
let url = match ep {
DockerEndpoint::Socket { path } => path.trim().to_owned(),
DockerEndpoint::Tcp { host, .. } => {
let host = host.trim();
if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") }
}
DockerEndpoint::Default => {
match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) {
Some(url) if !url.is_empty() => url,
_ => context_host(ep, None)?,
}
}
DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?,
};
Ok(daemon_url_is_local(&url))
}
/// The daemon URL of context `name` (the current one with `None`).
fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult<String> {
let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"];
args.extend(name);
Ok(run(ep, &args, QUICK)?.trim().to_owned())
}
/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are
/// this machine; `ssh://` and other hosts are not.
pub fn daemon_url_is_local(url: &str) -> bool {
let url = url.trim();
let Some((scheme, rest)) = url.split_once("://") else { return true };
match scheme {
"unix" | "npipe" => true,
"tcp" | "http" | "https" => {
let host = rest.split('/').next().unwrap_or_default();
let host = match host.strip_prefix('[') {
Some(v6) => v6.split(']').next().unwrap_or_default(),
None => host.rsplit_once(':').map_or(host, |(h, _)| h),
};
matches!(host, "localhost" | "127.0.0.1" | "::1")
}
_ => false,
}
}
/// `docker compose config --format json` of the project at `dir` (`files`
/// relative to it; none = the project's default file).
pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult<String> {
let mut args: Vec<&str> = vec!["compose"];
for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file", f]);
}
args.extend(["config", "--format", "json"]);
let mut cmd = command(ep, &args)?;
cmd.current_dir(dir);
let out = process::run(cmd, QUICK, None)?;
if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) }
}
#[cfg(test)]
mod local_tests {
use super::*;
#[test]
fn local_daemon_urls() {
for url in [
"unix:///var/run/docker.sock",
"/var/run/docker.sock",
"npipe:////./pipe/docker_engine",
"tcp://localhost:2375",
"tcp://127.0.0.1:2376",
"tcp://[::1]:2375",
"",
] {
assert!(daemon_url_is_local(url), "{url}");
}
for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] {
assert!(!daemon_url_is_local(url), "{url}");
}
assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap());
assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap());
}
}
+1 -28
View File
@@ -98,15 +98,7 @@ fn parse_unary(t: &[Tok], p: &mut usize, src: &str) -> Result<(), String> {
*p += 1;
Ok(())
}
Some(Tok::Id(id)) => {
if let Some(path) = id.strip_prefix("workspaceContains:")
&& (path.is_empty()
|| path.starts_with('/')
|| path.contains('\\')
|| path.split('/').any(|s| s == ".."))
{
return Err(format!("workspaceContains: needs a relative path inside the project: {src}"));
}
Some(Tok::Id(_)) => {
*p += 1;
if matches!(t.get(*p), Some(Tok::Op("==" | "!="))) {
*p += 1;
@@ -141,23 +133,4 @@ mod tests {
assert!(validate_when(bad).is_err(), "{bad}");
}
}
#[test]
fn workspace_contains_paths_stay_in_the_project() {
for ok in [
"workspaceContains:pom.xml",
"workspaceContains:.devcontainer/devcontainer.json",
"!workspaceContains:a/b && editorFocus",
] {
assert!(validate_when(ok).is_ok(), "{ok}");
}
for bad in [
"workspaceContains:",
"workspaceContains:/etc/passwd",
"workspaceContains:../x",
"workspaceContains:a/../../x",
] {
assert!(validate_when(bad).unwrap_err().contains("workspaceContains"), "{bad}");
}
}
}
+16 -221
View File
@@ -3,7 +3,7 @@
use crate::Host;
use ide_core::event::NotifyLevel;
use ide_core::settings::{ExternalFormatter, LanguagesSettings};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Pos, Range, TextEdit};
use ide_core::{DocId, IdeError, IdeEvent, IdeResult, Range, TextEdit};
use ide_language::LanguageConfig;
use ide_lsp::types::FormatOptions;
use ide_tools::{Tools, formatter};
@@ -11,27 +11,20 @@ use std::path::Path;
const OXFMT_CONFIGS: [&str; 4] = [".oxfmtrc.json", ".oxfmtrc.jsonc", "oxfmt.config.ts", "oxfmt.config.mts"];
fn supports_external_format(language: &LanguageConfig, tool: ExternalFormatter) -> bool {
match language.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => matches!(tool, ExternalFormatter::Prettier | ExternalFormatter::Oxfmt),
Some("ruff") => tool == ExternalFormatter::Ruff,
_ => false,
}
}
/// The external formatter of a file, for a language with an external formatter.
/// `forced` names the tool (Reformat with Prettier, oxfmt, or Ruff). Ruff is
/// the base formatter for Python; oxfmt can replace Prettier for its languages
/// when configured or selected. A tool that cannot format the language is
/// rejected even when explicitly requested.
/// `forced` names the tool (Reformat with Prettier/oxfmt). Otherwise oxfmt
/// applies when it is enabled for the language and either an oxfmt
/// configuration is found above the file or oxfmt is the selected provider;
/// else Prettier, when it is enabled. Turning Prettier off for a language
/// leaves it to the language server, not to oxfmt.
fn external_tool(
forced: Option<ExternalFormatter>,
language: Option<&LanguageConfig>,
file: &Path,
settings: &LanguagesSettings,
) -> IdeResult<Option<ExternalFormatter>> {
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
let Some(language) = language.filter(|l| l.formatter.is_some()) else {
use ExternalFormatter::{Oxfmt, Prettier};
let Some(language) = language.filter(|l| l.formatter.as_ref().is_some_and(|f| f.tool == "prettier")) else {
return match forced {
Some(tool) => Err(IdeError::invalid(format!(
"{} cannot format {} files",
@@ -41,21 +34,10 @@ fn external_tool(
None => Ok(None),
};
};
if let Some(tool) = forced {
return if supports_external_format(language, tool) {
Ok(Some(tool))
} else {
Err(IdeError::invalid(format!(
"{} cannot format {} files",
formatter::display_name(tool.name()),
language.name
)))
};
if forced.is_some() {
return Ok(forced);
}
let enabled = |tool| settings.formatter(tool).languages.iter().any(|l| l == language.id.as_str());
if language.formatter.as_ref().is_some_and(|f| f.tool == "ruff") {
return Ok(enabled(Ruff).then_some(Ruff));
}
let oxfmt_config = || OXFMT_CONFIGS.iter().any(|name| formatter::find_up(file, Path::new(name)).is_some());
Ok(if enabled(Oxfmt) && (settings.oxfmt.prefer || oxfmt_config()) {
Some(Oxfmt)
@@ -64,61 +46,6 @@ fn external_tool(
})
}
fn codepoint_col(line: &str, utf16_col: u32) -> u32 {
let mut units = 0;
for (index, ch) in line.chars().enumerate() {
if units >= utf16_col {
return index as u32;
}
units += ch.len_utf16() as u32;
}
line.chars().count() as u32
}
fn format_range(text: &ide_text::Document, range: Range) -> formatter::FormatRange {
let position = |pos: Pos| {
let line = text.line(pos.line).unwrap_or_default();
(pos.line + 1, codepoint_col(&line, pos.col) + 1)
};
let (start_line, start_col) = position(range.start);
let (end_line, end_col) = position(range.end);
formatter::FormatRange {
start_offset: text.pos_to_offset(range.start),
end_offset: text.pos_to_offset(range.end),
start_line,
start_col,
end_line,
end_col,
}
}
/// The edits PHP-CS-Fixer makes to the unsaved text: it fixes a temporary copy with the file's
/// name (from stdin it doesn't fix anything), so the file on disk stays as it is.
fn php_cs_fixer_edits(
tools: &Tools,
php: Option<&Path>,
fixer: &formatter::PhpCsFixer,
path: &Path,
unsaved: &str,
) -> IdeResult<Vec<TextEdit>> {
let name = path.file_name().ok_or_else(|| IdeError::invalid("PHP-CS-Fixer: file has no name"))?;
let dir = tempfile::tempdir()?;
let copy = dir.path().join(name);
std::fs::write(&copy, unsaved)?;
formatter::run_php_cs_fixer(tools.php_cs_fixer_command(php, fixer, &copy)?, &Default::default())?;
let formatted = std::fs::read_to_string(&copy)?;
let text = ide_text::Document::new(unsaved);
Ok(text
.diff(&formatted)
.changes
.into_iter()
.map(|c| TextEdit {
range: Range::new(text.offset_to_pos(c.from), text.offset_to_pos(c.to)),
new_text: c.insert,
})
.collect())
}
pub async fn format(
host: &Host,
window: &str,
@@ -130,34 +57,6 @@ pub async fn format(
let snap = project.documents.snapshot(doc)?;
let settings = project.settings.effective();
let language = project.languages.get(&snap.language);
// A committed PHP-CS-Fixer configuration takes over Reformat Code for `.php` files.
if options.tool.is_none()
&& snap.path.extension().is_some_and(|e| e == "php")
&& let Some(fixer) = formatter::php_cs_fixer(&snap.path)
{
if options.on_save && !settings.editor.format_on_save {
return Ok(Vec::new());
}
host.require_trust(window)?;
if snap.read_only {
return Err(IdeError::invalid("The file is read-only"));
}
let php = ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter);
let tools = Tools::new(&host.local_data_dir);
let edits = tokio::task::spawn_blocking(move || {
php_cs_fixer_edits(&tools, php.as_deref(), &fixer, &snap.path, &snap.text)
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if range.is_some() {
project.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Info,
title: "PHP-CS-Fixer".into(),
body: Some("PHP-CS-Fixer does not support selection formatting; formatted the whole file".into()),
});
}
return Ok(edits);
}
let mut selected = external_tool(options.tool, language.as_ref(), &snap.path, &settings.languages)?;
if let Some(tool) = selected
&& options.tool.is_none()
@@ -186,28 +85,13 @@ pub async fn format(
let custom = ide_sdk::apply::setting_path(&project.root, &tool_settings.path);
let tools = Tools::new(&host.local_data_dir);
let whole_selection = range.is_some() && !formatter::supports_range(tool, &config);
let project_root = project.root.clone();
let configuration_directory = settings.languages.ruff.configuration_directory.clone();
let edits: IdeResult<Vec<TextEdit>> = tokio::task::spawn_blocking(move || {
let text = ide_text::Document::new(&snap.text);
let range = range.map(|range| format_range(&text, range));
let config_file = if tool == "ruff" {
ide_sdk::apply::ruff_configuration(&project_root, &configuration_directory)?
} else {
None
};
let offsets = range.map(|r| (text.pos_to_offset(r.start), text.pos_to_offset(r.end)));
let mut command = tools.formatter_command(tool, &snap.path, custom.as_deref())?;
let dir = snap.path.parent().ok_or_else(|| IdeError::invalid(format!("{name}: file has no directory")))?;
command.current_dir(dir);
command.args(formatter::arguments(
tool,
&config,
&snap.path,
options.tab_size,
options.insert_spaces,
range,
config_file.as_deref(),
));
command.args(formatter::arguments(tool, &config, &snap.path, options.tab_size, options.insert_spaces, offsets));
let formatted = formatter::run(tool, command, &snap.text, &Default::default())?;
Ok(text
.diff(&formatted)
@@ -236,7 +120,7 @@ pub async fn format(
#[cfg(test)]
mod tests {
use super::*;
use ExternalFormatter::{Oxfmt, Prettier, Ruff};
use ExternalFormatter::{Oxfmt, Prettier};
use ide_core::LanguageId;
use ide_language::LanguageRegistry;
@@ -251,36 +135,17 @@ mod tests {
}
#[test]
fn default_settings_use_ruff_for_python_and_prettier_for_the_web() {
fn default_settings_give_every_external_formatter_language_to_prettier() {
let settings = LanguagesSettings::default();
let file = Path::new("/project/file");
for lang in LanguageRegistry::load(None).unwrap().all() {
let configured = match lang.formatter.as_ref().map(|f| f.tool.as_str()) {
Some("prettier") => Some(Prettier),
Some("ruff") => Some(Ruff),
_ => None,
};
let configured = lang.formatter.as_ref().is_some_and(|f| f.tool == "prettier");
let selected = external_tool(None, Some(&lang), file, &settings).unwrap();
assert_eq!(selected, configured, "{}", lang.id);
assert_eq!(selected, configured.then_some(Prettier), "{}", lang.id);
}
assert_eq!(external_tool(None, None, file, &settings).unwrap(), None);
}
#[test]
fn ruff_is_the_python_external_formatter_and_is_opt_out() {
let file = Path::new("/project/src/example.py");
let mut settings = LanguagesSettings::default();
assert_eq!(tool(None, "python", file, &settings).unwrap(), Some(Ruff));
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
settings.ruff.languages.clear();
assert_eq!(tool(None, "python", file, &settings).unwrap(), None);
assert_eq!(tool(Some(Ruff), "python", file, &settings).unwrap(), Some(Ruff));
let prettier = tool(Some(Prettier), "python", file, &settings).unwrap_err();
assert_eq!(prettier.message, "Prettier cannot format Python files");
let oxfmt = tool(Some(Oxfmt), "python", file, &settings).unwrap_err();
assert_eq!(oxfmt.message, "oxfmt cannot format Python files");
}
#[test]
fn oxfmt_config_selects_oxfmt_for_its_enabled_languages() {
let dir = tempfile::tempdir().unwrap();
@@ -327,74 +192,4 @@ mod tests {
let rust = tool(Some(Oxfmt), "rust", &dir.path().join("main.rs"), &settings).unwrap_err();
assert_eq!(rust.message, "oxfmt cannot format Rust files");
}
#[test]
fn php_cs_fixer_needs_a_config_and_the_project_binary() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src/Http")).unwrap();
let file = root.join("src/Http/Kernel.php");
assert_eq!(formatter::php_cs_fixer(&file), None);
std::fs::write(root.join(".php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file), None, "no vendor/bin/php-cs-fixer");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::write(root.join("vendor/bin/php-cs-fixer"), "").unwrap();
let found = formatter::php_cs_fixer(&file).unwrap();
assert_eq!(found.config, root.join(".php-cs-fixer.dist.php"));
assert_eq!(found.bin, root.join("vendor/bin/php-cs-fixer"));
std::fs::write(root.join(".php-cs-fixer.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join(".php-cs-fixer.php"));
std::fs::write(root.join("src/.php-cs-fixer.dist.php"), "<?php").unwrap();
assert_eq!(formatter::php_cs_fixer(&file).unwrap().config, root.join("src/.php-cs-fixer.dist.php"));
}
#[cfg(unix)]
#[test]
fn php_cs_fixer_fixes_a_copy_of_the_unsaved_text() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let fixer = formatter::PhpCsFixer { config: root.join(".php-cs-fixer.dist.php"), bin: root.join("fixer") };
std::fs::write(&fixer.config, "<?php").unwrap();
let php = root.join("php");
// Stands in for `php vendor/bin/php-cs-fixer fix … <file>`: rewrites the last argument.
std::fs::write(
&php,
"#!/bin/sh\nfor last; do :; done\ncase \"$last\" in */Kernel.php) ;; *) exit 3 ;; esac\n\
[ \"$2\" = fix ] || exit 4\nsed 's/ echo/ echo/' \"$last\" > \"$last.new\" && mv \"$last.new\" \"$last\"\n",
)
.unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
let file = root.join("src/Kernel.php");
let tools = Tools::new(&root.join("tools"));
let edits = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n echo 1;\n").unwrap();
assert_eq!(edits.len(), 1);
assert_eq!(edits[0].new_text, " ");
assert!(!file.exists(), "the file on disk is left alone");
std::fs::write(&php, "#!/bin/sh\necho 'Configuration file is invalid' >&2\nexit 16\n").unwrap();
let err = php_cs_fixer_edits(&tools, Some(&php), &fixer, &file, "<?php\n").unwrap_err();
assert!(
err.message.starts_with("PHP-CS-Fixer: ") && err.message.contains("Configuration file is invalid"),
"{}",
err.message
);
}
#[test]
fn ruff_ranges_are_one_based_unicode_columns() {
let text = ide_text::Document::new("😀x = 1\ny = 2\n");
let range = Range::new(Pos::new(0, 2), Pos::new(1, 2));
assert_eq!(
format_range(&text, range),
formatter::FormatRange {
start_offset: 2,
end_offset: 10,
start_line: 1,
start_col: 2,
end_line: 2,
end_col: 3,
}
);
}
}
-51
View File
@@ -10,7 +10,6 @@ mod java_runtime;
mod kotlin_bridge;
pub mod notebook;
pub mod ops;
mod phpstan;
pub mod plugins;
mod project;
mod proxy;
@@ -229,9 +228,6 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.agents.shutdown();
project.notebooks.stop_admission();
@@ -245,7 +241,6 @@ impl Host {
project.kotlin_bridge.iter().cloned().collect(),
Some(project.lsp.clone()),
Some((self.debugger.clone(), window.to_owned())),
Some(project),
))
}
@@ -258,7 +253,6 @@ impl Host {
bridges: Vec<Arc<kotlin_bridge::KotlinBridge>>,
lsp: Option<Arc<ide_lsp::LspManager>>,
debug: Option<(Arc<ide_dap::DebugManager>, String)>,
retired: Option<Arc<Project>>,
) -> std::thread::JoinHandle<()> {
for bridge in &bridges {
bridge.stop();
@@ -268,10 +262,6 @@ impl Host {
}
let runtime = self.runtime.clone();
std::thread::spawn(move || {
// Dropping the project stops its file watcher and index worker, which first finish the
// batch they are applying (many file events while a build writes its output), so it
// isn't done on the closing caller's thread (the UI thread for a window close).
let retired = retired.map(|project| std::thread::spawn(move || drop(project)));
runtime.block_on(async move {
for bridge in bridges {
bridge.shutdown().await;
@@ -286,9 +276,6 @@ impl Host {
debug.stop_key(&key).await;
}
});
if let Some(retired) = retired {
let _ = retired.join();
}
})
}
@@ -328,7 +315,6 @@ impl Host {
projects.iter().filter_map(|project| project.kotlin_bridge.clone()).collect(),
None,
None,
None,
));
let deadline = std::time::Instant::now() + EXIT_SHUTDOWN_WAIT;
while pending.iter().any(|handle| !handle.is_finished()) {
@@ -363,9 +349,6 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.stop();
}
if let Some(phpstan) = &project.phpstan {
phpstan.stop();
}
project.documents.clear_workspace_edit_history();
project.notebooks.stop_admission();
project.agents.shutdown();
@@ -432,37 +415,3 @@ impl Host {
self.projects.write().insert(window.to_owned(), project);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn detached_close_releases_the_project_before_a_reopen() {
let temp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(temp.path()).unwrap();
let root = base.join("project");
std::fs::create_dir(&root).unwrap();
std::fs::write(root.join("main.rs"), "fn main() {}\n").unwrap();
let host = Host::new(HostConfig {
config_dir: base.join("config"),
data_dir: base.join("data"),
local_data_dir: base.join("local"),
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,
});
let project = Arc::downgrade(&host.open_project("main", &root).unwrap());
host.close_project_detached("main");
assert!(host.project("main").is_err());
// The project (its file watcher and index database) is dropped off the closing thread, but
// before opening a project waits out the close.
host.wait_detached_closes();
assert!(project.upgrade().is_none(), "the closed project is dropped");
host.open_project("main", &root).unwrap();
host.close_project("main");
}
}
+3 -13
View File
@@ -1,7 +1,7 @@
//! Project-incarnation notebook ownership and whole-batch execution admission.
use crate::ops::Channel;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, SharedSink};
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
use ide_notebook::kernel::{KernelManager, KernelOutput};
use ide_notebook::{NotebookDocument, NotebookEdit, NotebookSnapshot};
use parking_lot::Mutex;
@@ -17,7 +17,7 @@ use tokio::sync::{Mutex as AsyncMutex, watch};
#[serde(rename_all = "camelCase")]
pub struct NotebookOutputEvent {
pub id: String,
pub path: IpcPath,
pub path: String,
pub cell: String,
pub msg_id: String,
pub kind: String,
@@ -536,7 +536,7 @@ fn commit_output(
};
Ok(Some(NotebookOutputEvent {
id: document.id.clone(),
path: document.path.clone().into(),
path: document.path.to_string_lossy().into_owned(),
cell: cell.to_owned(),
msg_id: output.msg_id,
kind: output.kind,
@@ -592,16 +592,6 @@ mod tests {
other.shutdown().await;
}
#[tokio::test]
async fn the_notebook_path_reaches_the_ui_as_the_project_tree_names_it() {
let (dir, service, _) = fixture();
let ui_path =
format!("{}/session.ipynb", ide_core::ipc_path::to_ipc(&dunce::canonicalize(dir.path()).unwrap()));
let snapshot = service.open(Path::new(&ui_path)).unwrap();
assert_eq!(serde_json::to_value(&snapshot).unwrap()["path"], ui_path);
service.shutdown().await;
}
#[tokio::test(start_paused = true)]
async fn failed_close_keeps_the_incarnation_recoverable_until_a_retry_succeeds() {
let (dir, service, initial) = fixture();
+1 -1
View File
@@ -32,7 +32,7 @@ pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportS
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// with paths mapped into this project.
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = host.project(window)?.root.clone();
-8
View File
@@ -162,11 +162,6 @@ pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, th
host.debugger.pause(session, thread_id).await
}
/// Types `text` into the session's program (a `runInTerminal` program).
pub async fn debug_send_input(host: &Host, _window: &str, session: DebugSessionId, text: String) -> IdeResult<()> {
host.debugger.send_input(session, &text)
}
pub async fn debug_hot_reload(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
host.debugger.hot_reload(session).await
}
@@ -386,9 +381,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
"debug_pause",
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
);
d.add("debug_send_input", |c| async move {
debug_send_input(&c.host, &c.window, c.arg("session")?, c.arg("text")?).await
});
d.add("debug_step", |c| async move {
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
});
-23
View File
@@ -242,29 +242,6 @@ mod tests {
assert!(build().is_err());
assert_eq!(fs::read_to_string(root.join("calls")).unwrap().lines().count(), 5);
}
/// An external tool (e.g. an AI code editor) rewrites a source file with the same byte
/// length and preserves its mtime. The `(length, mtime)` fingerprint reads that as
/// "unchanged" and would reuse the stale snapshot; the content hash must catch it.
#[test]
fn external_edit_preserving_length_and_mtime_rebuilds_the_snapshot() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let model = fixture(root);
let cache = root.join("cache");
let cancel = CancellationToken::new();
let mut session = Session::new(cache.clone());
let first = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_eq!(fs::read_to_string(first.snapshot.join("0/Main.class")).unwrap(), "first");
let source = root.join("src/main/kotlin/Main.kt");
let mtime = fs::metadata(&source).unwrap().modified().unwrap();
fs::write(&source, "other").unwrap(); // 5 bytes, same length as "first"
fs::File::options().write(true).open(&source).unwrap().set_modified(mtime).unwrap();
let rebuilt = session.build(root, &model, root, false, None, &cancel).unwrap();
assert_ne!(rebuilt.snapshot, first.snapshot, "a same-length, same-mtime content change must rebuild");
assert_eq!(fs::read_to_string(rebuilt.snapshot.join("0/Main.class")).unwrap(), "other");
}
#[test]
fn session_drop_removes_superseded_snapshots_and_preserves_records_across_reopen() {
let dir = tempfile::tempdir().unwrap();
-26
View File
@@ -257,28 +257,6 @@ pub async fn lsp_implementations(
result
}
/// Go to Implementation of the symbol at `pos`. Implementations found before a slower
/// search for the rest (Java: the project's, before the dependencies') arrive on
/// `on_partial`; the result has them all. Cancel with `cancel_request(request)`.
pub async fn lsp_goto_implementation(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
host.end_request(request);
result
}
/// 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> {
@@ -768,10 +746,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("lsp_implementations", |c| async move {
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
});
d.add("lsp_goto_implementation", |c| async move {
let on_partial = c.sender("onPartial")?;
lsp_goto_implementation(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, on_partial).await
});
d.add(
"lsp_resolve_class_file",
|c| async move { lsp_resolve_class_file(&c.host, &c.window, c.arg("path")?).await },
+2 -41
View File
@@ -1,6 +1,5 @@
use super::Emit;
use crate::Host;
use ide_core::remote_settings::RunTargetKind;
use ide_core::{IdeError, IdeResult, RunId};
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
use std::time::Duration;
@@ -141,33 +140,12 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
if config.command.is_empty() {
return Err(IdeError::invalid("run configuration has no command"));
}
// Settings › Run Targets: over SSH, or in a container.
// Settings › Run Targets: over SSH.
if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
return Err(IdeError::invalid("Tomcat Local requires the local machine"));
}
if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) {
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
let settings = project.settings.effective();
let target = ide_remote::service::run_target(&settings, id)?;
if target.kind != RunTargetKind::Ssh {
// A local run of the docker CLI: the command is resolved in the container, and
// this machine's toolchains and `PYTHONPATH` don't apply.
let config = container::prepare(host, &project.root, target, config).await?;
let encoding = settings.console.encoding;
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: Vec::new(),
// A folder on this machine: not in the container.
ruby_reporter: None,
};
project.require_trust()?;
return host.processes.start(
&project.root,
&config,
&options,
project.sink.clone(),
Box::new(move |c| on_output(c)),
);
}
let out = on_output.clone();
return host
.remote
@@ -220,7 +198,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
let options = StartOptions {
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
ruby_reporter: ruby_reporter_dir(host, &config),
};
project.require_trust()?;
let mut sink = project.sink.clone();
@@ -237,21 +214,6 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
}
/// The Ruby test reporter's directory, installed on demand, for runs that take it.
fn ruby_reporter_dir(host: &Host, config: &RunConfig) -> Option<std::path::PathBuf> {
if !ide_run::ruby_reporter::applies(config) {
return None;
}
let dir = host.local_data_dir.join("ruby-reporter");
match ide_run::ruby_reporter::install(&dir) {
Ok(()) => Some(dir),
Err(e) => {
tracing::warn!("cannot install the Ruby test reporter in {}: {e}", dir.display());
None
}
}
}
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
let settings = match host.project(window) {
@@ -293,5 +255,4 @@ pub fn list(host: &Host) -> Vec<RunInfo> {
}
mod commands;
mod container;
pub use commands::*;
-108
View File
@@ -1,108 +0,0 @@
//! Docker and Compose run targets: the configuration becomes a local run of the
//! docker CLI (see `ide_remote::docker`).
use crate::Host;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_docker::DockerEndpoint;
use ide_remote::docker::{
ContainerRun, compose_container_root, container_config, container_name, default_container_root,
};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// `config` rewritten to run in a new container of `target`'s image or service.
pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult<RunConfig> {
if config.kind == "dotnet" || config.kind == "dotnet-test" {
return Err(IdeError::invalid(".NET Project requires the local machine"));
}
let endpoint = endpoint(&host.data_dir, &target)?;
let root = root.to_path_buf();
host.blocking(move || {
if !ide_docker::endpoint_is_local(&endpoint)? {
return Err(IdeError::invalid(format!(
"Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one",
target.name
)));
}
let docker = ide_docker::docker_executable().ok_or_else(|| {
IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH")
})?;
let container_root = match target.container_root.trim() {
"" if target.kind == RunTargetKind::Compose => {
let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?;
compose_container_root(&json, target.service.trim(), &root)?
}
"" => default_container_root(&root),
folder => folder.to_owned(),
};
let file_env = env_files(&root, &config)?;
let name = container_name(&config.id, &unique());
container_config(
&config,
&ContainerRun {
target: &target,
root: &root,
container_root: &container_root,
docker: &docker.to_string_lossy(),
endpoint_env: &ide_docker::run_env(&endpoint),
file_env: &file_env,
name: &name,
user: user(&root, &target),
},
)
})
.await
}
/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon.
fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult<DockerEndpoint> {
let id = target.docker_connection.trim();
if id.is_empty() {
return Ok(DockerEndpoint::Default);
}
ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else(
|| {
IdeError::not_found(format!(
"Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets",
target.name
))
},
)
}
/// The configuration's `.env` files, read here: the container gets their variables.
fn env_files(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
let extras = &config.extras;
if extras.env_files.is_empty() {
return Ok(BTreeMap::new());
}
let paths: Vec<PathBuf> = extras
.env_files
.iter()
.map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) })
.collect();
let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() };
ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}")))
}
/// On Linux the container writes files as the project folder's owner, unless the
/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves.
fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> {
#[cfg(target_os = "linux")]
if !target.image_user {
use std::os::unix::fs::MetadataExt;
return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid()));
}
let _ = (root, target);
None
}
/// Distinguishes runs of the same configuration.
fn unique() -> String {
use std::sync::atomic::{AtomicU32, Ordering};
static COUNT: AtomicU32 = AtomicU32::new(0);
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed))
}
-40
View File
@@ -52,14 +52,6 @@ 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 interpreter chosen in Settings › Languages & Frameworks › PHP; `None`: the `php` on `PATH`.
pub fn sdk_php_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
host.require_trust(window)?;
let project = host.project(window)?;
let settings = project.settings.effective();
Ok(ide_sdk::apply::setting_path(&project.root, &settings.languages.php.interpreter).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)]
@@ -96,33 +88,6 @@ pub async fn sdk_php_language_level(host: &Host, window: &str, path: PathBuf) ->
.map_err(join_err)
}
/// The Composer project `path` belongs to, when its dependencies need `composer install`.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct ComposerInstallState {
pub root: ide_core::IpcPath,
pub reason: ide_sdk::php::ComposerInstall,
}
/// `None` in an untrusted project too: `composer install` runs the project's scripts.
pub async fn sdk_php_composer_install(
host: &Host,
window: &str,
path: PathBuf,
) -> IdeResult<Option<ComposerInstallState>> {
let project = host.project(window)?;
if !project.is_trusted() {
return Ok(None);
}
let root = project.root.clone();
tokio::task::spawn_blocking(move || {
ide_sdk::php::composer_install(&path, &root)
.map(|(root, reason)| ComposerInstallState { root: ide_core::IpcPath::from(root), reason })
})
.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>> {
@@ -233,15 +198,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_effective", |c| async move { sdk_php_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_php_composer_install",
|c| async move { sdk_php_composer_install(&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
+1 -5
View File
@@ -24,11 +24,7 @@ fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
.collect();
// Godot is usually an app bundle / Flatpak outside the search path.
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
let work_dir = (!root.as_os_str().is_empty()).then_some(root);
Ok(Tools::new(&data)
.with_custom_paths(custom)
.with_found_paths(found.into_iter().collect())
.with_work_dir(work_dir))
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
}
/// The language definitions plus the servers defined in the window's settings.
+26 -16
View File
@@ -405,6 +405,28 @@ pub async fn vcs_log_details_batch(
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,
@@ -1007,19 +1029,6 @@ pub async fn vcs_diff_revisions(
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
/// Added and deleted lines between two revisions. `paths` limits the diff; empty diffs every file.
pub async fn vcs_diff_numstat(
host: &Host,
window: &str,
repo: Option<PathBuf>,
from: String,
to: String,
paths: Vec<String>,
) -> IdeResult<Vec<FileLineStats>> {
let project = LogAccess::new(host, window, repo.as_deref());
blocking(move || project.with_log_repo_at(repo.as_deref(), |r| r.diff_numstat(&from, &to, &paths))).await
}
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
/// Request tab's three-dot diff.
pub async fn vcs_merge_base(
@@ -1213,6 +1222,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
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
});
@@ -1370,9 +1383,6 @@ pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_diff_numstat", |c| async move {
vcs_diff_numstat(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?, c.arg("paths")?).await
});
d.add("vcs_merge_base", |c| async move {
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
});
-850
View File
@@ -1,850 +0,0 @@
//! Saved PHP files -> `vendor/bin/phpstan analyse` -> `phpstan` diagnostics.
use ide_core::event::NotifyLevel;
use ide_core::output_encoding::decode_tool_output;
use ide_core::{CancellationToken, Diagnostic, IdeEvent, Pos, Range, Severity, SharedSink};
use ide_workspace::SettingsStore;
use ide_workspace::documents::DocumentObserver;
use parking_lot::Mutex;
use serde_json::Value;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use std::time::Duration;
use tokio::sync::Notify;
const DELAY: Duration = Duration::from_millis(750);
const SOURCE: &str = "phpstan";
/// Saved files waiting for a check. Each save cancels the wait or the check in progress.
#[derive(Default)]
struct Queue {
generation: u64,
cancel: CancellationToken,
files: BTreeSet<PathBuf>,
}
impl Queue {
fn push(&mut self, files: impl IntoIterator<Item = PathBuf>) {
self.files.extend(files);
self.cancel.cancel();
self.generation += 1;
self.cancel = CancellationToken::new();
}
fn current(&self, generation: u64) -> bool {
self.generation == generation && !self.cancel.is_cancelled()
}
}
pub(crate) struct Phpstan {
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
lifetime: CancellationToken,
queue: Mutex<Queue>,
changed: Notify,
/// Files with PHPStan diagnostics, the only ones a clean result or a removal clears.
shown: Mutex<HashSet<PathBuf>>,
}
impl Phpstan {
pub(crate) fn new(
root: PathBuf,
config_dir: PathBuf,
settings: Arc<SettingsStore>,
sink: SharedSink,
trusted: Arc<AtomicBool>,
) -> Arc<Self> {
Arc::new(Self {
root,
config_dir,
settings,
sink,
trusted,
lifetime: CancellationToken::new(),
queue: Mutex::new(Queue::default()),
changed: Notify::new(),
shown: Mutex::new(HashSet::new()),
})
}
pub(crate) fn start(self: &Arc<Self>, runtime: &tokio::runtime::Handle) {
let checker = self.clone();
runtime.spawn(async move { checker.run().await });
}
pub(crate) fn stop(&self) {
self.lifetime.cancel();
self.queue.lock().cancel.cancel();
}
/// Stops the check in progress (trust changed); its files wait for the next one, which drops
/// them while the project is in Restricted Mode.
pub(crate) fn interrupt(&self) {
self.queue.lock().push([]);
self.changed.notify_one();
}
fn files_saved(&self, paths: impl IntoIterator<Item = PathBuf>) {
let mut files = paths.into_iter().filter(|p| p.extension().is_some_and(|e| e == "php")).peekable();
if self.lifetime.is_cancelled() || files.peek().is_none() {
return;
}
self.queue.lock().push(files);
self.changed.notify_one();
}
/// Deleted files and folders lose their diagnostics.
pub(crate) fn files_removed<'a>(&self, removed: impl Iterator<Item = &'a PathBuf>) {
let removed: Vec<&PathBuf> = removed.collect();
let gone: Vec<PathBuf> =
self.shown.lock().iter().filter(|p| removed.iter().any(|r| p.starts_with(r))).cloned().collect();
for path in gone {
self.publish(path, Vec::new());
}
}
fn publish(&self, path: PathBuf, items: Vec<Diagnostic>) {
let mut shown = self.shown.lock();
if items.is_empty() {
if !shown.remove(&path) {
return;
}
} else {
shown.insert(path.clone());
}
// Under the lock, so a removal and a result for one file reach the UI in the order decided here.
self.sink.emit(IdeEvent::Diagnostics { path: path.into(), source: SOURCE.into(), items });
}
async fn run(self: Arc<Self>) {
// The failure last reported per root: each is reported once, until a run succeeds.
let mut failures: BTreeMap<PathBuf, String> = BTreeMap::new();
loop {
tokio::select! {
_ = self.lifetime.cancelled() => return,
_ = self.changed.notified() => {},
}
let (generation, cancel) = {
let queue = self.queue.lock();
(queue.generation, queue.cancel.clone())
};
if !debounce(&cancel, &self.lifetime).await {
continue;
}
let files = {
let mut queue = self.queue.lock();
if !queue.current(generation) {
continue;
}
std::mem::take(&mut queue.files)
};
if !self.trusted.load(Ordering::Acquire) {
continue;
}
let (root, config_dir, settings) = (self.root.clone(), self.config_dir.clone(), self.settings.clone());
let Ok((checks, unchecked)) =
tokio::task::spawn_blocking(move || plan(&root, &config_dir, &settings, files)).await
else {
continue;
};
for path in unchecked {
self.publish(path, Vec::new());
}
let mut checks = checks.into_iter();
while let Some(check) = checks.next() {
let started = std::time::Instant::now();
let program = check.command.get_program().to_string_lossy().into_owned();
let output = analyse(check.command, &cancel).await;
let Some(output) = output.filter(|_| self.queue.lock().current(generation)) else {
// A newer save: these files are checked again with it.
let mut queue = self.queue.lock();
queue.files.extend(check.files);
queue.files.extend(checks.by_ref().flat_map(|c| c.files));
break;
};
tracing::debug!(
"phpstan checked {} file(s) in {} ms",
check.files.len(),
started.elapsed().as_millis()
);
// Files deleted meanwhile make the result (or the failure) moot: check the rest again.
let (present, missing): (Vec<PathBuf>, Vec<PathBuf>) =
check.files.iter().cloned().partition(|f| f.is_file());
if !missing.is_empty() {
for path in missing {
self.publish(path, Vec::new());
}
self.files_saved(present);
continue;
}
let report = match output {
Ok(output) => {
let parsed = parse(&decode_tool_output(&output.stdout), &decode_tool_output(&output.stderr));
parsed.map_err(|text| {
if text.is_empty() {
let status = output.status;
format!("PHPStan ended with {status} and no report")
} else {
text
}
})
}
Err(error) => Err(format!("Cannot run {program}: {error}")),
};
let failure = match report {
Ok(report) => {
let files = check.files.clone();
let results = tokio::task::spawn_blocking(move || diagnostics(&files, report.files))
.await
.unwrap_or_default();
for (path, items) in results {
// Severe errors (a syntax error) stop the analysis of the other files.
if report.complete || !items.is_empty() {
self.publish(path, items);
}
}
report.errors.join("\n")
}
Err(text) => {
// Without a report the earlier errors of these files may be stale.
for path in check.files {
self.publish(path, Vec::new());
}
text
}
};
if failure.is_empty() {
failures.remove(&check.root);
} else {
self.failure(&check.root, failure, &mut failures);
}
}
}
}
fn failure(&self, root: &Path, text: String, failures: &mut BTreeMap<PathBuf, String>) {
if failures.get(root) == Some(&text) {
return;
}
self.sink.emit(IdeEvent::Notify {
level: NotifyLevel::Error,
title: "PHPStan check failed".into(),
body: Some(text.clone()),
});
failures.insert(root.to_path_buf(), text);
}
}
impl DocumentObserver for Phpstan {
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
self.files_saved([path.to_path_buf()]);
}
}
async fn debounce(cancel: &CancellationToken, lifetime: &CancellationToken) -> bool {
tokio::select! {
_ = lifetime.cancelled() => false,
_ = cancel.cancelled() => false,
_ = tokio::time::sleep(DELAY) => true,
}
}
/// One `vendor/bin/phpstan` run: the saved files below one PHP project.
struct Check {
root: PathBuf,
files: Vec<PathBuf>,
command: Command,
}
/// The runs that check `files`, and the files not checked, whose earlier diagnostics no longer
/// apply. A file belongs to the innermost linked project (with its own settings) that contains it,
/// else to the project; deleted files, files in `vendor` or in excluded folders, and files of a
/// project without PHPStan are not checked.
fn plan(
project: &Path,
config_dir: &Path,
settings: &SettingsStore,
files: BTreeSet<PathBuf>,
) -> (Vec<Check>, Vec<PathBuf>) {
let members =
ide_workspace::workspace::Workspace::load(project).ok().flatten().map(|w| w.projects).unwrap_or_default();
let mut groups: BTreeMap<PathBuf, Vec<PathBuf>> = BTreeMap::new();
let mut unchecked = Vec::new();
for file in files {
let member = members.iter().filter(|m| file.starts_with(m)).max_by_key(|m| m.components().count());
let root = member.cloned().or_else(|| file.starts_with(project).then(|| project.to_path_buf()));
match root {
Some(root) if file.is_file() => groups.entry(root).or_default().push(file),
_ => unchecked.push(file),
}
}
let mut checks = Vec::new();
for (root, files) in groups {
let effective = if root == project {
settings.effective()
} else {
SettingsStore::load(config_dir, Some(&root)).effective()
};
let excludes = ide_vfs::ExcludeMatcher::new(&effective.exclude_globs());
let (files, skipped): (Vec<PathBuf>, Vec<PathBuf>) = files.into_iter().partition(|f| {
f.strip_prefix(&root).is_ok_and(|rel| !rel.starts_with("vendor") && !excludes.is_excluded(rel))
});
unchecked.extend(skipped);
match ide_sdk::php::phpstan_command(&root, &effective, &files).filter(|_| !files.is_empty()) {
Some(command) => checks.push(Check { root, files, command }),
None => unchecked.extend(files),
}
}
(checks, unchecked)
}
/// Runs `cmd` to its end; `None` when `cancel` fires first, which kills it.
async fn analyse(mut cmd: Command, cancel: &CancellationToken) -> Option<std::io::Result<Output>> {
if cancel.is_cancelled() {
return None;
}
ide_core::process::prepare(&mut cmd);
ide_core::process::own_group(&mut cmd);
cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
let mut cmd = tokio::process::Command::from(cmd);
cmd.kill_on_drop(true);
let child = match cmd.spawn() {
Ok(child) => child,
Err(error) => return Some(Err(error)),
};
let pid = child.id();
// PHPStan may start workers of its own.
let cancelled = async {
cancel.cancelled().await;
if let Some(pid) = pid {
let _ = tokio::task::spawn_blocking(move || ide_core::process::kill_tree_of(pid)).await;
}
};
tokio::select! {
output = child.wait_with_output() => Some(output),
() = cancelled => None,
}
}
/// A PHPStan message: 0-based line, text with its tip, identifier.
#[derive(Debug, PartialEq)]
struct Message {
line: u32,
text: String,
identifier: Option<String>,
}
/// A JSON report: messages per file as PHPStan names it, the errors of the run itself, and
/// whether every file was analysed (severe errors such as a syntax error stop the others).
#[derive(Debug, PartialEq)]
struct Report {
files: BTreeMap<String, Vec<Message>>,
errors: Vec<String>,
complete: bool,
}
impl Default for Report {
fn default() -> Self {
Self { files: BTreeMap::new(), errors: Vec::new(), complete: true }
}
}
/// PHPStan's `--error-format=json` report, or the text to report when there is none (an
/// invalid configuration, a crash). PHP warnings, or a byte order mark a bootstrap file prints,
/// may precede the report on stdout.
fn parse(stdout: &str, stderr: &str) -> Result<Report, String> {
let json = serde_json::from_str::<Value>(stdout.trim().trim_start_matches('\u{feff}')).ok().or_else(|| {
stdout.lines().rev().find_map(|l| l.find('{').and_then(|start| serde_json::from_str(&l[start..]).ok()))
});
let Some(Value::Object(json)) = json else {
// Every saved file is excluded by the configuration (`excludePaths`).
if stderr.contains("No files found to analyse") {
return Ok(Report::default());
}
return Err(failure_text(stdout, stderr));
};
let mut report = Report::default();
if let Some(Value::Object(files)) = json.get("files") {
for (file, result) in files {
let messages = result.get("messages").and_then(Value::as_array).into_iter().flatten();
report.files.insert(file.clone(), messages.filter_map(message).collect());
}
}
let errors = json.get("errors").and_then(Value::as_array).into_iter().flatten();
report.errors = errors.map(|e| e.as_str().map_or_else(|| e.to_string(), str::to_owned)).collect();
report.complete = !stderr.contains("Result is incomplete");
Ok(report)
}
fn message(m: &Value) -> Option<Message> {
let mut text = m.get("message")?.as_str()?.to_owned();
if let Some(tip) = m.get("tip").and_then(Value::as_str).filter(|t| !t.trim().is_empty()) {
text = format!("{text}\n{}", strip_console_tags(tip));
}
let line = m.get("line").and_then(Value::as_u64).unwrap_or(1).max(1) - 1;
Some(Message {
line: u32::try_from(line).unwrap_or(u32::MAX),
text,
identifier: m.get("identifier").and_then(Value::as_str).map(str::to_owned),
})
}
/// Tips may carry console styling (`<fg=cyan>…</>`).
fn strip_console_tags(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(i) = rest.find('<') {
out.push_str(&rest[..i]);
rest = &rest[i..];
let tag = rest.find('>').map(|end| &rest[..=end]);
match tag {
Some(tag)
if tag == "</>"
|| tag.starts_with("<fg=")
|| tag.starts_with("<bg=")
|| tag.starts_with("<options=") =>
{
rest = &rest[tag.len()..];
}
_ => {
out.push('<');
rest = &rest[1..];
}
}
}
out.push_str(rest);
out
}
/// What PHPStan printed instead of a report, without its notes and blank lines.
fn failure_text(stdout: &str, stderr: &str) -> String {
let lines: Vec<&str> = stderr
.lines()
.chain(stdout.lines())
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with("Note: Using configuration file"))
.collect();
lines.iter().take(20).copied().collect::<Vec<_>>().join("\n")
}
/// The diagnostics of each checked file (none for a clean one), matched to the report's files
/// by their real paths. Errors PHPStan reports in a trait name the class using it
/// (`Trait.php (in context of class Foo)`).
fn diagnostics(files: &[PathBuf], reported: BTreeMap<String, Vec<Message>>) -> Vec<(PathBuf, Vec<Diagnostic>)> {
let real = |p: &Path| dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
let mut messages: BTreeMap<PathBuf, Vec<Message>> = BTreeMap::new();
for (name, list) in reported {
let name = name.split(" (in context of ").next().unwrap_or(&name);
messages.entry(real(Path::new(name))).or_default().extend(list);
}
files
.iter()
.map(|file| {
let list = messages.remove(&real(file)).unwrap_or_default();
// Columns only: a file in another encoding than UTF-8 still gives the right lines.
let text = if list.is_empty() {
None
} else {
std::fs::read(file).ok().map(|bytes| String::from_utf8_lossy(&bytes).into_owned())
};
let items = list.into_iter().map(|m| diagnostic(m, text.as_deref())).collect();
(file.clone(), items)
})
.collect()
}
fn diagnostic(m: Message, text: Option<&str>) -> Diagnostic {
Diagnostic {
range: line_range(text, m.line),
severity: Severity::Warning,
message: m.text,
source: Some("PHPStan".into()),
server: Some(SOURCE.into()),
code: m.identifier,
unnecessary: false,
deprecated: false,
}
}
/// PHPStan reports lines only: the line from its first non-blank character to its end (UTF-16).
fn line_range(text: Option<&str>, line: u32) -> Range {
let text = text.map(|t| t.strip_prefix('\u{feff}').unwrap_or(t));
let content = text.and_then(|t| t.split('\n').nth(line as usize)).unwrap_or("").trim_end();
let indent = content.len() - content.trim_start().len();
let col = |s: &str| u32::try_from(s.encode_utf16().count()).unwrap_or(u32::MAX);
Range { start: Pos { line, col: col(&content[..indent]) }, end: Pos { line, col: col(content) } }
}
#[cfg(test)]
mod tests {
use super::*;
const REPORT: &str = r#"{"totals":{"errors":0,"file_errors":3},"files":{
"/p/src/A.php":{"errors":2,"messages":[
{"message":"Constant UNKNOWN not found.","line":11,"ignorable":true,
"tip":"Learn more at https://phpstan.org/user-guide/discovering-symbols","identifier":"constant.notFound"},
{"message":"Access to an undefined property.","line":3,"ignorable":true,
"tip":"Learn more: <fg=cyan>https://phpstan.org/blog/solving</> for array<int, string>","identifier":"property.notFound"}]},
"/p/src/T.php (in context of class App\\A)":{"errors":1,"messages":[
{"message":"Syntax error","line":null,"ignorable":false,"identifier":"phpstan.parse"}]}},
"errors":[]}"#;
fn message(line: u32, text: &str, identifier: &str) -> Message {
Message { line, text: text.into(), identifier: Some(identifier.into()) }
}
#[test]
fn messages_keep_their_line_tip_and_identifier() {
let report = parse(REPORT, "Note: Using configuration file /p/phpstan.neon.").unwrap();
assert!(report.errors.is_empty());
assert_eq!(
report.files["/p/src/A.php"],
[
message(
10,
"Constant UNKNOWN not found.\nLearn more at https://phpstan.org/user-guide/discovering-symbols",
"constant.notFound"
),
message(
2,
"Access to an undefined property.\nLearn more: https://phpstan.org/blog/solving for array<int, string>",
"property.notFound"
),
]
);
assert_eq!(
report.files["/p/src/T.php (in context of class App\\A)"],
[message(0, "Syntax error", "phpstan.parse")]
);
}
#[test]
fn errors_of_the_run_are_not_file_messages() {
let stdout =
r#"{"totals":{"errors":1,"file_errors":0},"files":[],"errors":["Invalid entries in excludePaths"]}"#;
let report = parse(stdout, "").unwrap();
assert!(report.files.is_empty());
assert_eq!(report.errors, ["Invalid entries in excludePaths"]);
// PHP warnings ahead of the report, or a byte order mark printed by a bootstrap file.
let warned = format!("Deprecated: something in /p/x.php on line 3\n{stdout}");
assert_eq!(parse(&warned, "").unwrap().errors, ["Invalid entries in excludePaths"]);
let marked = format!("\u{feff}{stdout}");
assert_eq!(parse(&marked, "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert_eq!(parse(&format!("Notice: x{stdout}"), "").unwrap().errors, ["Invalid entries in excludePaths"]);
assert!(parse(stdout, "").unwrap().complete);
let severe = " Result is incomplete because of severe errors.";
assert!(!parse(stdout, severe).unwrap().complete);
}
#[test]
fn output_without_a_report_is_a_failure() {
let stderr = "Note: Using configuration file /p/phpstan.neon.\n\n Invalid entry in excludePaths:\n Path \"/p/nothere\" is neither a directory\n\n";
assert_eq!(
parse("", stderr),
Err("Invalid entry in excludePaths:\nPath \"/p/nothere\" is neither a directory".to_string())
);
assert_eq!(
parse("Fatal error: Allowed memory size exhausted", ""),
Err("Fatal error: Allowed memory size exhausted".into())
);
assert_eq!(parse("", ""), Err(String::new()));
// Excluded files are no failure: nothing to report.
assert_eq!(parse("", " [ERROR] No files found to analyse.\n"), Ok(Report::default()));
}
#[test]
fn checked_files_get_their_messages_and_clean_files_none() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
let (a, t, clean) = (root.join("A.php"), root.join("T.php"), root.join("Clean.php"));
std::fs::write(&a, "<?php\n\n echo $é; \r\n").unwrap();
std::fs::write(&t, "<?php\n").unwrap();
std::fs::write(&clean, "<?php\n").unwrap();
let reported = BTreeMap::from([
(a.to_string_lossy().into_owned(), vec![message(2, "Undefined variable", "variable.undefined")]),
(format!("{} (in context of class A)", t.display()), vec![message(0, "In a trait", "trait.x")]),
("/elsewhere/B.php".to_string(), vec![message(0, "Not checked", "x")]),
]);
let out = diagnostics(&[a.clone(), t.clone(), clean.clone()], reported);
assert_eq!(
out.iter().map(|(p, items)| (p.clone(), items.len())).collect::<Vec<_>>(),
[(a, 1), (t, 1), (clean, 0)]
);
let d = &out[0].1[0];
// From the first non-blank character to the end of the line, in UTF-16 columns.
assert_eq!(d.range, Range { start: Pos { line: 2, col: 4 }, end: Pos { line: 2, col: 12 } });
assert_eq!(
(d.code.as_deref(), d.source.as_deref(), d.server.as_deref(), d.severity),
(Some("variable.undefined"), Some("PHPStan"), Some("phpstan"), Severity::Warning)
);
assert_eq!(line_range(None, 7), Range { start: Pos { line: 7, col: 0 }, end: Pos { line: 7, col: 0 } });
}
#[tokio::test(start_paused = true)]
async fn burst_saves_give_one_check_of_all_files() {
let mut queue = Queue::default();
queue.push([PathBuf::from("/p/A.php")]);
let token = queue.cancel.clone();
let lifetime = CancellationToken::new();
let pending_lifetime = lifetime.clone();
let waiting = tokio::spawn(async move { debounce(&token, &pending_lifetime).await });
tokio::task::yield_now().await;
tokio::time::advance(DELAY - Duration::from_millis(1)).await;
assert!(!waiting.is_finished());
for i in 0..20 {
queue.push([PathBuf::from(format!("/p/{}.php", i % 3))]);
}
assert!(!waiting.await.unwrap());
let current = queue.generation;
assert!(debounce(&queue.cancel, &lifetime).await);
assert!(queue.current(current));
assert_eq!(queue.files.len(), 4);
lifetime.cancel();
assert!(!debounce(&queue.cancel, &lifetime).await);
}
/// A project with PHPStan whose PHP logs each run, waits `pause` seconds, prints `stderr` and
/// reports `report`. `pause` is read before the run is logged, so a test that sees the run can change it.
#[cfg(unix)]
struct Fixture {
_dir: tempfile::TempDir,
root: PathBuf,
events: Arc<Mutex<Vec<IdeEvent>>>,
trusted: Arc<AtomicBool>,
checker: Arc<Phpstan>,
}
#[cfg(unix)]
impl Fixture {
fn new() -> Self {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(dir.path()).unwrap();
let root = base.join("project");
std::fs::create_dir_all(root.join("vendor/bin")).unwrap();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::write(root.join("vendor/bin/phpstan"), "<?php\n").unwrap();
// Named php8.5: the selected PHP runs vendor/bin/phpstan, whatever its name.
let php = base.join("php/php8.5");
std::fs::create_dir_all(php.parent().unwrap()).unwrap();
let script = "#!/bin/sh\nshift\npause=\"$(cat pause 2>/dev/null || echo 0)\"\n\
echo \"$XDEBUG_MODE $*\" >> runs.log\nsleep \"$pause\"\ncat stderr >&2 2>/dev/null\ncat report\n";
std::fs::write(&php, script).unwrap();
std::fs::set_permissions(&php, std::fs::Permissions::from_mode(0o755)).unwrap();
std::fs::create_dir_all(root.join(".openidea")).unwrap();
let settings = format!("[languages.php]\ninterpreter = {:?}\n", php.to_string_lossy());
std::fs::write(root.join(".openidea/settings.toml"), settings).unwrap();
let config = base.join("config");
let settings = Arc::new(SettingsStore::load(&config, Some(&root)));
let events = Arc::new(Mutex::new(Vec::new()));
let sink_events = events.clone();
let sink: SharedSink = Arc::new(move |e| sink_events.lock().push(e));
let trusted = Arc::new(AtomicBool::new(true));
let checker = Phpstan::new(root.clone(), config, settings, sink, trusted.clone());
checker.start(&tokio::runtime::Handle::current());
Self { _dir: dir, root, events, trusted, checker }
}
fn file(&self, name: &str) -> PathBuf {
let path = self.root.join("src").join(name);
std::fs::write(&path, "<?php\necho UNKNOWN;\n").unwrap();
path
}
fn report(&self, path: &Path, identifiers: &[&str]) {
let messages: Vec<Value> = identifiers
.iter()
.map(|id| serde_json::json!({"message": "Bad", "line": 2, "identifier": id, "ignorable": true}))
.collect();
let files = if messages.is_empty() {
serde_json::json!({})
} else {
serde_json::json!({ path.to_string_lossy(): {"errors": messages.len(), "messages": messages} })
};
std::fs::write(self.root.join("report"), serde_json::json!({"files": files, "errors": []}).to_string())
.unwrap();
}
fn runs(&self) -> Vec<String> {
std::fs::read_to_string(self.root.join("runs.log")).unwrap_or_default().lines().map(str::to_owned).collect()
}
async fn until(&self, what: &str, done: impl Fn(&Self) -> bool) {
for _ in 0..200 {
if done(self) {
return;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
panic!("timed out waiting for {what}: runs {:?}, events {:?}", self.runs(), self.events.lock());
}
fn diagnostics(&self) -> Vec<(PathBuf, Vec<Option<String>>)> {
self.events
.lock()
.iter()
.filter_map(|e| match e {
IdeEvent::Diagnostics { path, source, items } if source == SOURCE => {
Some((path.to_path_buf(), items.iter().map(|d| d.code.clone()).collect()))
}
_ => None,
})
.collect()
}
fn notifications(&self) -> Vec<String> {
let events = self.events.lock();
events.iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).map(|e| format!("{e:?}")).collect()
}
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn saves_are_checked_together_and_a_newer_save_cancels_the_check() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
for _ in 0..5 {
f.checker.files_saved([a.clone(), f.root.join("src/readme.txt")]);
}
f.until("one check", |f| f.diagnostics().len() == 1).await;
assert_eq!(f.runs(), [format!("off analyse --error-format=json --no-progress {}", a.display())]);
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())])]);
// A save during a check kills it; the next check covers both files.
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the slow check", |f| f.runs().len() == 2).await;
std::fs::write(f.root.join("pause"), "0").unwrap();
let b = f.file("B.php");
f.report(&a, &[]);
f.checker.files_saved([b.clone()]);
f.until("the check of both", |f| f.runs().len() == 3 && f.diagnostics().len() == 2).await;
assert!(f.runs()[2].ends_with(&format!("{} {}", a.display(), b.display())), "{:?}", f.runs());
// A clean result clears A; B never had diagnostics, so nothing is sent for it.
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_failing_run_is_reported_once_and_projects_without_phpstan_stay_quiet() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the first check", |f| f.diagnostics().len() == 1).await;
std::fs::write(f.root.join("report"), "").unwrap();
for run in 2..=3 {
f.checker.files_saved([a.clone()]);
f.until("the failing check", |f| f.runs().len() == run).await;
}
tokio::time::sleep(Duration::from_millis(200)).await;
let notes = f.notifications();
assert_eq!(notes.len(), 1, "{notes:?}");
assert!(notes[0].contains("PHPStan check failed") && notes[0].contains("no report"), "{notes:?}");
// The failed check leaves no stale errors.
assert_eq!(f.diagnostics(), [(a.clone(), vec![Some("constant.notFound".to_string())]), (a.clone(), vec![])]);
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
let events = f.events.lock().len();
f.checker.files_saved([a.clone()]);
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.events.lock().len(), events);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_a_severe_error_left_unchecked_keep_their_diagnostics() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check of A", |f| f.diagnostics().len() == 1).await;
// A syntax error in B: PHPStan reports B only and says the result is incomplete.
f.report(&b, &["phpstan.parse"]);
std::fs::write(f.root.join("stderr"), "Result is incomplete because of severe errors.").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check of both", |f| f.diagnostics().len() == 2).await;
tokio::time::sleep(Duration::from_millis(200)).await;
assert_eq!(f.diagnostics()[1], (b, vec![Some("phpstan.parse".to_string())]));
assert_eq!(f.diagnostics().len(), 2, "A is not cleared");
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn a_file_deleted_during_its_check_gets_no_result_and_no_failure() {
let f = Fixture::new();
let (a, b) = (f.file("A.php"), f.file("B.php"));
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "1").unwrap();
f.checker.files_saved([a.clone(), b.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
std::fs::remove_file(&a).unwrap();
// B is checked again on its own.
f.until("the check of B", |f| f.runs().len() == 2).await;
assert!(f.runs()[1].ends_with(&format!("--no-progress {}", b.display())), "{:?}", f.runs());
tokio::time::sleep(Duration::from_millis(300)).await;
assert!(f.events.lock().is_empty(), "{:?}", f.events.lock());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn restricted_mode_stops_the_check_in_progress() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
std::fs::write(f.root.join("pause"), "30").unwrap();
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.runs().len() == 1).await;
f.trusted.store(false, Ordering::Release);
f.checker.interrupt();
tokio::time::sleep(DELAY * 2).await;
assert_eq!(f.runs().len(), 1);
assert!(f.events.lock().is_empty());
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn files_no_longer_checked_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
// PHPStan removed from the project: saving A no longer checks it and drops its errors.
std::fs::remove_file(f.root.join("vendor/bin/phpstan")).unwrap();
f.checker.files_saved([a.clone()]);
f.until("the clear", |f| f.diagnostics().len() == 2).await;
assert_eq!(f.diagnostics()[1], (a, vec![]));
assert_eq!(f.runs().len(), 1);
f.checker.stop();
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn removed_files_lose_their_diagnostics() {
let f = Fixture::new();
let a = f.file("A.php");
f.report(&a, &["constant.notFound"]);
f.checker.files_saved([a.clone()]);
f.until("the check", |f| f.diagnostics().len() == 1).await;
f.checker.files_removed([f.root.join("src/other")].iter());
assert_eq!(f.diagnostics().len(), 1);
f.checker.files_removed([f.root.join("src")].iter());
assert_eq!(f.diagnostics()[1], (a, vec![]));
f.checker.stop();
}
}
+5 -99
View File
@@ -2,7 +2,6 @@
//! Capabilities were checked by the runtime before any of this runs.
use super::{PluginEvent, PluginEventSink};
use ide_core::ipc_path::to_ipc;
use ide_core::{CancellationToken, TextEdit};
use ide_extension::Package;
use ide_language::ProgramPin;
@@ -30,11 +29,6 @@ pub trait PluginWorkspace: Send + Sync {
fn projects(&self) -> Vec<PathBuf>;
/// Unsaved text of the open document at `path`.
fn buffer_text(&self, path: &Path) -> Option<String>;
/// Up to `limit` files of the project index below `root` whose root-relative path satisfies
/// `matches`; `None` when no index covers `root` yet (the caller walks the disk instead).
fn indexed_files(&self, _root: &Path, _matches: &dyn Fn(&Path) -> bool, _limit: usize) -> Option<Vec<PathBuf>> {
None
}
/// Version and text of the open document at `path`.
fn document(&self, path: &Path) -> Option<(u64, String)>;
/// Applies `edits` to the open document at `path` if it is still at
@@ -311,9 +305,8 @@ impl PluginApi {
// allowed, and installing it later approves nothing by itself.
let pin = self.shared.pins.read().get(&(self.id().to_owned(), cap.clone())).cloned().ok_or_else(|| {
HostError::Failed(format!(
"{cap}: `{program}` was not found on PATH when this permission was allowed. \
Restarting doesn't approve a program installed later: once `{program}` is on PATH, \
clear {cap} in Settings › Plugins and select it again"
"{cap}: `{program}` was not found when this permission was allowed; \
install it, then allow {cap} again in Settings › Plugins"
))
})?;
pin.verify().map(Path::to_path_buf).map_err(|e| HostError::Denied(format!("{cap}: {e}")))
@@ -426,7 +419,7 @@ impl HostApi for PluginApi {
let info = repo.branch_info().map_err(failed)?;
let changed = repo.status().map_err(failed)?.len();
Ok(Some(RepositoryStatus {
root: to_ipc(repo.root()),
root: repo.root().to_string_lossy().into_owned(),
branch: info.head,
commit: info.head_commit,
upstream: info.upstream,
@@ -464,19 +457,6 @@ impl HostApi for PluginApi {
.build()
.map_err(|e| HostError::Invalid(format!("{glob:?}: {e}")))?
.compile_matcher();
// Hidden files and folders are left out, as in the walk below.
let visible = |rel: &Path| !rel.iter().any(|c| c.to_str().is_some_and(|c| c.starts_with('.')));
// The project index already lists the files: walking a large tree on every call can take
// longer than an editor provider's deadline (the plugin is then stopped as crashed).
if let Some(files) = self
.shared
.workspace()
.and_then(|w| w.indexed_files(root, &|rel| visible(rel) && matcher.is_match(rel), limit))
{
let mut out: Vec<String> = files.iter().map(|p| to_ipc(p)).collect();
out.sort();
return Ok(out);
}
let mut out = Vec::new();
for (n, entry) in ignore::WalkBuilder::new(root).hidden(true).build().flatten().enumerate() {
if n >= MAX_WALK || out.len() >= limit {
@@ -487,7 +467,7 @@ impl HostApi for PluginApi {
}
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
if matcher.is_match(rel) {
out.push(to_ipc(entry.path()));
out.push(entry.path().to_string_lossy().into_owned());
}
}
out.sort();
@@ -756,12 +736,6 @@ impl PluginWorkspace for HostWorkspace {
HostWorkspace::projects(self).iter().find_map(|p| p.documents.text_of(path))
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
// The index of the project rooted at `root` (a workspace member's files are in its own).
let project = HostWorkspace::projects(self).into_iter().find(|p| p.root == root)?;
project.index.files_matching(root, matches, limit)
}
fn document(&self, path: &Path) -> Option<(u64, String)> {
let (project, doc) = self.open_document(path)?;
let snap = project.documents.snapshot(doc).ok()?;
@@ -832,73 +806,6 @@ mod tests {
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
}
#[test]
fn repository_and_search_paths_match_ipc_events() {
let tmp = tempfile::tempdir().unwrap();
assert!(std::process::Command::new("git").args(["init", "-q"]).arg(tmp.path()).status().unwrap().success());
let root = tmp.path().canonicalize().unwrap();
std::fs::create_dir(root.join("src")).unwrap();
let file = root.join("src/Example.java");
std::fs::write(&file, "class Example {}\n").unwrap();
let api = api(&root, Arc::default());
assert_eq!(api.find_files(&root, "**/*.java", 8).unwrap(), [to_ipc(&file)]);
assert_eq!(api.vcs_status(&root).unwrap().unwrap().root, to_ipc(&root));
}
/// A project index that lists `files` (root-relative), whatever is on disk.
struct Indexed(PathBuf, Vec<&'static str>);
impl PluginWorkspace for Indexed {
fn projects(&self) -> Vec<PathBuf> {
vec![self.0.clone()]
}
fn buffer_text(&self, _: &Path) -> Option<String> {
None
}
fn document(&self, _: &Path) -> Option<(u64, String)> {
None
}
fn apply_edits(&self, _: &Path, _: u64, _: &[TextEdit]) -> Result<u64, HostError> {
Err(HostError::Stale)
}
fn trusted(&self, _: &Path) -> bool {
true
}
fn project_of(&self, _: &str) -> Option<PathBuf> {
None
}
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
Ok(())
}
fn indexed_files(&self, root: &Path, matches: &dyn Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
(root == self.0).then(|| {
self.1.iter().map(Path::new).filter(|rel| matches(rel)).take(limit).map(|rel| root.join(rel)).collect()
})
}
}
#[test]
fn find_files_uses_the_project_index_when_there_is_one() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().canonicalize().unwrap();
let api = api(&root, Arc::default());
*api.shared.workspace.write() = Some(Arc::new(Indexed(
root.clone(),
vec!["src/b/UserMapper.xml", "src/a/OrderMapper.xml", ".idea/workspace.xml", "pom.xml"],
)));
// Listed by the index, not walked: none of these exist on disk.
assert_eq!(
api.find_files(&root, "**/*Mapper.xml", 10).unwrap(),
[to_ipc(&root.join("src/a/OrderMapper.xml")), to_ipc(&root.join("src/b/UserMapper.xml"))]
);
assert_eq!(api.find_files(&root, "**/*.xml", 10).unwrap().len(), 3, "hidden folders are left out");
assert_eq!(api.find_files(&root, "**/*.xml", 1).unwrap().len(), 1);
// Another root: no index covers it, so the disk is walked.
let other = root.join("other");
std::fs::create_dir_all(&other).unwrap();
std::fs::write(other.join("a.xml"), "").unwrap();
assert_eq!(api.find_files(&other, "*.xml", 10).unwrap(), [to_ipc(&other.join("a.xml"))]);
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
@@ -910,8 +817,7 @@ mod tests {
// Allowed while the program was missing: say how to fix it rather than "not granted".
let err = api.approved_program("lint").unwrap_err().to_string();
assert!(
err.contains("was not found on PATH when this permission was allowed")
&& err.contains("clear process:lint in Settings › Plugins and select it again"),
err.contains("was not found when this permission was allowed") && err.contains("allow process:lint again"),
"{err}"
);
+1 -23
View File
@@ -55,7 +55,6 @@ pub struct Project {
/// Native notebooks and owned kernels, scoped to this project incarnation.
pub notebooks: Arc<crate::notebook::NotebookService>,
pub(crate) kotlin_bridge: Option<Arc<crate::kotlin_bridge::KotlinBridge>>,
pub(crate) phpstan: Option<Arc<crate::phpstan::Phpstan>>,
pub(crate) watcher: Watcher,
/// Watchers of the Project Structure content roots outside the project folder.
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
@@ -293,21 +292,12 @@ impl Host {
trusted.clone(),
);
documents.add_observer(kotlin_bridge.clone());
let phpstan = crate::phpstan::Phpstan::new(
root.clone(),
self.config_dir.clone(),
settings.clone(),
sink.clone(),
trusted.clone(),
);
documents.add_observer(phpstan.clone());
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
for repo in repos.read().all() {
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
}
let watcher = {
let kotlin_bridge = kotlin_bridge.clone();
let phpstan = phpstan.clone();
let repos = repos.clone();
let settings = settings.clone();
let index = index.clone();
@@ -331,7 +321,6 @@ impl Host {
let (ignored, ignored_gone) = ignored_changes(batch);
index.update(&[&changed[..], &ignored].concat(), &[&batch.removed[..], &ignored_gone].concat());
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
phpstan.files_removed(batch.removed.iter());
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed, &batch.excluded_dirs);
if composer_changed(&root, changed.iter().chain(&batch.removed)) {
@@ -451,7 +440,6 @@ impl Host {
&history,
&lsp,
Some(&kotlin_bridge),
Some(&phpstan),
)?;
Some((r.clone(), w))
})
@@ -498,7 +486,6 @@ impl Host {
agents,
notebooks,
kotlin_bridge: Some(kotlin_bridge),
phpstan: Some(phpstan),
watcher,
content_watchers: Mutex::new(content_watchers),
lsp_watchers: Mutex::new(Vec::new()),
@@ -529,9 +516,6 @@ impl Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.start(&self.runtime);
}
if let Some(phpstan) = &project.phpstan {
phpstan.start(&self.runtime);
}
if let Some(cmd) = post_start {
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
}
@@ -642,7 +626,6 @@ impl Host {
agents,
notebooks,
kotlin_bridge: None,
phpstan: None,
watcher,
content_watchers: Mutex::new(Vec::new()),
lsp_watchers: Mutex::new(Vec::new()),
@@ -1019,7 +1002,6 @@ impl Project {
&self.history,
&self.lsp,
self.kotlin_bridge.as_ref(),
self.phpstan.as_ref(),
) {
watchers.push((r, w));
}
@@ -1155,9 +1137,8 @@ fn content_root_watcher(
history: &Arc<LocalHistory>,
lsp: &Arc<LspManager>,
kotlin_bridge: Option<&Arc<crate::kotlin_bridge::KotlinBridge>>,
phpstan: Option<&Arc<crate::phpstan::Phpstan>>,
) -> Option<Watcher> {
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
let kotlin_bridge = kotlin_bridge.cloned();
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
let root = dir.to_path_buf();
Watcher::start(
@@ -1175,9 +1156,6 @@ fn content_root_watcher(
if let Some(bridge) = &kotlin_bridge {
bridge.files_changed(changed.iter().chain(&batch.removed));
}
if let Some(phpstan) = &phpstan {
phpstan.files_removed(batch.removed.iter());
}
documents.on_disk_changed(&[&changed[..], &ignored].concat());
record_external_changes(&history, &changed, &batch.removed);
}),
+1 -4
View File
@@ -109,9 +109,6 @@ impl crate::Host {
if let Some(bridge) = &project.kotlin_bridge {
bridge.schedule();
}
if let Some(phpstan) = &project.phpstan {
phpstan.interrupt();
}
if !trusted {
project.lsp.set_execution_allowed(false);
}
@@ -319,6 +316,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_log_users"
| "vcs_log_details"
| "vcs_log_details_batch"
| "vcs_log_index"
| "vcs_revision_children"
| "vcs_revision_files"
| "vcs_open_external_log"
@@ -338,7 +336,6 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
| "vcs_compare_scope"
| "vcs_compare_paths"
| "vcs_diff_revisions"
| "vcs_diff_numstat"
| "vcs_merge_base"
| "vcs_shelves"
| "vcs_shelf_patch"
-122
View File
@@ -1,122 +0,0 @@
//! Docker run targets through `ops::run::start`, with a stand-in `docker`
//! (`OPENIDEA_DOCKER`) that prints what it was given.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<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(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(target: &str, root: &Path) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: vec![root.join("main.py").to_string_lossy().into_owned()],
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
async fn output_of(host: &Host, config: RunConfig) -> String {
let out = Arc::new(Mutex::new(String::new()));
let sink = out.clone();
ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
for _ in 0..200 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
out.lock().clone()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn docker_targets_run_through_the_docker_cli() {
let tmp = tempfile::tempdir().unwrap();
let base = dunce::canonicalize(tmp.path()).unwrap();
let docker = base.join("docker");
std::fs::write(
&docker,
"#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\
for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n",
)
.unwrap();
std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap();
// SAFETY: the only test of this binary, before any thread looks docker up.
unsafe {
std::env::set_var("OPENIDEA_DOCKER", &docker);
std::env::remove_var("DOCKER_HOST");
}
let root = base.join("proj");
std::fs::create_dir_all(&root).unwrap();
let host = host(&base.join("app"));
std::fs::create_dir_all(&host.data_dir).unwrap();
std::fs::write(
host.data_dir.join("docker.json"),
r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#,
)
.unwrap();
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" },
{ "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let out = output_of(&host, config("py", &root)).await;
let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect();
assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}");
assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}");
assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}");
assert!(out.contains("DATA=/workspaces/proj/data"), "{out}");
assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line");
let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err();
assert!(err.message.contains("another machine"), "{}", err.message);
}
@@ -1,159 +0,0 @@
//! Docker and Compose run targets against a real daemon. Ignored by default:
//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount
//! (on this machine, or at the same path in a VM such as OrbStack's), and
//! `docker` with the Compose plugin must be on `PATH`.
#![cfg(unix)]
use ide_core::NullSink;
use ide_host::{Host, HostConfig, ops};
use ide_run::{OutputChunk, RunConfig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
fn host(base: &Path) -> Arc<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(NullSink)),
owner: |w| w,
runtime: tokio::runtime::Handle::current(),
project_environment: false,
}))
}
fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig {
RunConfig {
id: id.into(),
name: id.into(),
kind: "python".into(),
command: root.join(".venv/bin/python").to_string_lossy().into_owned(),
args: args.iter().map(|a| a.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() },
}
}
type Output = Arc<Mutex<String>>;
async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) {
let out: Output = Arc::default();
let sink = out.clone();
let info = ops::run::start(
host,
"main",
config,
Arc::new(move |chunk| match chunk {
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
sink.lock().push_str(&text)
}
}),
)
.await
.unwrap();
(info.id, out)
}
async fn finished(out: &Output, secs: u64) -> String {
for _ in 0..secs * 10 {
if out.lock().contains("Process finished") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
out.lock().clone()
}
fn containers(label: &str) -> String {
let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"]
async fn runs_in_docker_and_compose_containers() {
let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let root = dunce::canonicalize(root).unwrap();
let name = root.file_name().unwrap().to_string_lossy().into_owned();
std::fs::write(
root.join("main.py"),
"import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\
open('out.txt', 'w').write('ok')\n",
)
.unwrap();
std::fs::write(
root.join("compose.yaml"),
"services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n",
)
.unwrap();
let state = tempfile::tempdir().unwrap();
let host = host(state.path());
host.trust.decide(&root, true, false).unwrap();
host.open_project("main", &root).unwrap();
let targets = serde_json::json!([
{ "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" },
{ "kind": "compose", "id": "app", "name": "Compose", "service": "app" },
]);
ops::project::set_settings(
&host,
"main",
ide_workspace::settings::SettingsScope::Project,
"runTargets",
&targets.to_string(),
)
.unwrap();
let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await;
let out = finished(&out, 120).await;
assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}");
assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
let written = root.join("out.txt");
assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok");
#[cfg(target_os = "linux")]
{
use std::os::unix::fs::MetadataExt;
let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap());
assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner");
}
std::fs::remove_file(&written).unwrap();
assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends");
let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await;
let out = finished(&out, 120).await;
assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}");
std::fs::remove_file(&written).unwrap();
// Stop: `docker stop` ends the container; `--rm` removes it.
let (id, out) =
start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"]))
.await;
for _ in 0..600 {
if out.lock().contains("up\n") {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock());
ops::run::stop(&host, "main", id).unwrap();
let out = finished(&out, 30).await;
assert!(out.contains(" stop openidea-sleep-"), "{out}");
for _ in 0..100 {
if containers("label=openidea.run=sleep").is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert_eq!(containers("label=openidea.run=sleep"), "", "{out}");
Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap();
}
+5 -6
View File
@@ -6,7 +6,6 @@
//! `plugins/build.sh <dir>` after changing their source or `wit/`.
use ide_core::error::ErrorKind;
use ide_core::ipc_path::to_ipc;
use ide_host::plugins::*;
use ide_plugin::{EditorContext, HostError, Invocation, Limits};
use parking_lot::Mutex;
@@ -215,7 +214,7 @@ fn fixture(limits: Limits) -> Fixture {
std::fs::create_dir_all(project.join("src")).unwrap();
std::fs::write(project.join("src/notes.txt"), "one two three\nfour five\n").unwrap();
std::fs::write(tmp.path().join("secret.txt"), "top secret").unwrap();
let project = dunce::canonicalize(project).unwrap();
let project = project.canonicalize().unwrap();
let events: Events = Arc::default();
let sink = {
let events = events.clone();
@@ -360,7 +359,7 @@ fn read_file_is_confined_to_the_project() {
let inv = Invocation { client: None, workspace_root: None, editor: None };
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
assert!(err.contains("no project is open"), "{err}");
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), f.project.to_string_lossy());
assert_eq!(f.status("misbehave"), PluginStatus::Active);
}
@@ -1741,7 +1740,7 @@ fn official_checkstyle_checks_on_save() {
let java = f.project.join("src/A.java");
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
let saved = |f: &Fixture| {
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(java.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
};
saved(&f);
@@ -1794,7 +1793,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.workspace.diagnostics.lock().get(&("openidea.license-header".into(), path.to_path_buf())).cloned()
};
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(a.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
let d = diagnostics(&a).expect("published on open");
@@ -1819,7 +1818,7 @@ fn official_license_header_reports_fixes_and_scans() {
f.host.run_command("openidea.license-header", "scan", None, f.inv()).unwrap();
assert_eq!(diagnostics(&b).map(|d| d.len()), Some(1), "found by the scan");
std::fs::write(&a, "// Copyright (c) 2026 X\nfn a() {}\n").unwrap();
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(a.to_string_lossy().into_owned())]);
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
assert!(f.host.wait_events(Duration::from_secs(20)));
assert_eq!(diagnostics(&a).map(|d| d.len()), Some(0), "cleared once the header is there");
-2
View File
@@ -35,8 +35,6 @@ async fn opens_unity_without_generated_solutions_and_excludes_caches_from_the_in
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);
let listed = project.index.files_matching(&project.root, |rel| rel.ends_with("Player.cs"), 10).unwrap();
assert_eq!(listed.len(), 1);
for name in ["Cached.cs", "Temporary.cs", "Editor.log"] {
assert!(project.index.find_files(name, 100).is_empty(), "indexed generated file {name}");
}
-22
View File
@@ -197,28 +197,6 @@ impl ProjectIndex {
matching::find_files(&d, &matching::Recents { paths: &recent }, query, limit)
}
/// Up to `limit` indexed files below `dir` (VCS-ignored ones left out) whose path relative to
/// `dir` satisfies `matches`: a file list without walking the disk. `None` while nothing is
/// indexed yet (first scan without a cache).
pub fn files_matching(&self, dir: &Path, matches: impl Fn(&Path) -> bool, limit: usize) -> Option<Vec<PathBuf>> {
let d = self.shared.data.read();
if d.file_count == 0 {
return None;
}
let mut out = Vec::new();
for file in d.files.iter().flatten().filter(|f| !f.ignored) {
if out.len() >= limit {
break;
}
if let Ok(rel) = file.path.strip_prefix(dir)
&& matches(rel)
{
out.push(file.path.clone());
}
}
Some(out)
}
/// Fuzzy search for folders that hold indexed files; `rel_path` ends in `/`.
pub fn find_dirs(&self, query: &str, limit: usize) -> Vec<FileMatch> {
matching::find_dirs(&self.shared.data.read(), query, limit)
+1 -1
View File
@@ -52,7 +52,7 @@ tree-sitter-proto = "0.6.0"
tree-sitter-make = "1.1.1"
tree-sitter-cmake = "0.7.4"
tree-sitter-xml = "0.7.0"
tree-sitter-haskell = "0.24.1"
tree-sitter-haskell = "0.23.1"
tree-sitter-dart = "0.2.0"
tree-sitter-graphql = "0.3.0"
tree-sitter-gdscript = "6.1.0"
@@ -1,7 +1,7 @@
(header module: (module) @name) @outline.module
(data_type name: (name) @name) @def.enum
(newtype name: (name) @name) @def.struct
(type_synonym name: (name) @name) @def.type
(type_synomym name: (name) @name) @def.type
(type_family name: (name) @name) @def.type
(class name: (name) @name) @def.interface
(instance name: (name) @name patterns: (_) @detail) @outline.namespace
-1
View File
@@ -533,7 +533,6 @@ mod tests {
"Julia",
"Kotlin",
"LiveScript",
"Log",
"Lua",
"mIRC",
"Mathematica",
+1 -2
View File
@@ -162,8 +162,7 @@ pub fn client_capabilities() -> Value {
"callHierarchy": { "dynamicRegistration": false },
"typeHierarchy": { "dynamicRegistration": false }
},
// rust-analyzer: report when the project has loaded (`quiescent`).
"experimental": { "serverStatusNotification": true }
"experimental": {}
})
}
-11
View File
@@ -136,12 +136,6 @@ impl ClientHandler {
fn show_message(&self, mgr: &LspManager, params: &Value) {
let text = params.get("message").and_then(Value::as_str).unwrap_or_default().to_owned();
// rust-analyzer built from source reports its own slow main-loop turns as errors, meant for
// its developers (`poke_rust_analyzer_developer`); nothing for the user to act on.
if text.starts_with("overly long loop turn") {
tracing::warn!("[{}] {text}", self.server.name);
return;
}
let level = match params.get("type").and_then(Value::as_u64) {
Some(1) => NotifyLevel::Error,
Some(2) => NotifyLevel::Warning,
@@ -240,11 +234,6 @@ impl Handler for ClientHandler {
let Some(mgr) = self.mgr.upgrade() else { return };
match method {
"$/progress" => self.progress(&mgr, params),
"experimental/serverStatus" => {
if let Some(quiescent) = params.get("quiescent").and_then(Value::as_bool) {
self.server.set_loading(mgr.sink(), !quiescent);
}
}
"textDocument/publishDiagnostics" => self.publish_diagnostics(&mgr, params),
"window/showMessage" => self.show_message(&mgr, &params),
"window/logMessage" => {
+2 -85
View File
@@ -4,7 +4,7 @@ use ide_core::CancellationToken;
use ide_core::tool_path::search_path;
use ide_jsonrpc::{Handler, Peer};
use ide_language::LanguageServerConfig;
use std::ffi::{OsStr, OsString};
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -100,8 +100,7 @@ impl Launcher for ProcessLauncher {
// A plugin's server: the approved program, unchanged since.
Some(pin) => Some(pin.verify().map_err(LaunchError::Failed)?.to_path_buf()),
None => ide_core::tool_path::find_command(&config.command, &search, root)
.and_then(|b| resolve_rustup_proxy(&config.command, b))
.and_then(|b| reject_dead_shim(b, &search, root)),
.and_then(|b| resolve_rustup_proxy(&config.command, b)),
};
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
@@ -137,60 +136,6 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
config.env.get("PATH").map(OsString::from).unwrap_or_else(search_path)
}
/// What a version manager's shim says when the active version has no such tool.
const DEAD_SHIM_MESSAGES: &[&str] =
&["no version is set for", "no preset version", "command not found", "exists in these", "is not installed"];
/// `~/.local/share/mise/shims/ruby-lsp` exists for every version of the runtime that ever
/// had the tool, but exits with an error when the version active in `root` lacks it. A
/// server that dies like that on every start is missing (an install hint), not crashing.
/// Anything but a clear "not installed" answer from the shim counts as working.
fn reject_dead_shim(binary: PathBuf, search: &OsStr, root: &Path) -> Option<PathBuf> {
if !binary.parent().is_some_and(|d| d.file_name().is_some_and(|n| n == "shims")) {
return Some(binary);
}
let Ok(mut child) = ide_core::process::prepare(&mut std::process::Command::new(&binary))
.arg("--version")
.current_dir(root)
.env("PATH", search)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
else {
return Some(binary);
};
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let status = loop {
match child.try_wait() {
Ok(Some(status)) => break status,
Ok(None) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(20))
}
// Slow or unwaitable: let the real start decide.
_ => {
let _ = child.kill();
let _ = child.wait();
return Some(binary);
}
}
};
if status.success() {
return Some(binary);
}
let mut stderr = String::new();
if let Some(mut pipe) = child.stderr.take() {
let _ = std::io::Read::read_to_string(&mut pipe, &mut stderr);
}
let stderr = stderr.to_lowercase();
if DEAD_SHIM_MESSAGES.iter().any(|m| stderr.contains(m)) {
tracing::info!("{} is a version-manager shim without the tool: {}", binary.display(), stderr.trim());
None
} else {
Some(binary)
}
}
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
/// toolchain lacks the component (common for rust-analyzer). In that case use
/// the newest installed toolchain that ships the binary; with none, the tool is
@@ -274,34 +219,6 @@ mod tests {
std::fs::remove_dir_all(&tmp).unwrap();
}
#[cfg(unix)]
#[test]
fn shims_without_the_tool_are_missing() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::tempdir().unwrap();
let shims = tmp.path().join("mise/shims");
std::fs::create_dir_all(&shims).unwrap();
let script = |name: &str, body: &str| {
let p = shims.join(name);
std::fs::write(&p, format!("#!/bin/sh\n{body}\n")).unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o755)).unwrap();
p
};
let search = OsString::from("/usr/bin:/bin");
let dead = script("ruby-lsp", "echo 'mise ERROR No version is set for shim: ruby-lsp' >&2; exit 1");
assert_eq!(reject_dead_shim(dead, &search, tmp.path()), None);
let works = script("good", "echo 1.0");
assert_eq!(reject_dead_shim(works.clone(), &search, tmp.path()), Some(works));
// A tool with no `--version` that fails for its own reasons still starts.
let odd = script("odd", "echo 'usage: odd' >&2; exit 2");
assert_eq!(reject_dead_shim(odd.clone(), &search, tmp.path()), Some(odd));
// Only shims are probed.
let plain = tmp.path().join("ruby-lsp");
std::fs::write(&plain, "#!/bin/sh\necho 'No version is set for' >&2; exit 1\n").unwrap();
std::fs::set_permissions(&plain, std::fs::Permissions::from_mode(0o755)).unwrap();
assert_eq!(reject_dead_shim(plain.clone(), &search, tmp.path()), Some(plain));
}
#[test]
fn marked_root_needs_a_marker() {
let tmp = tempfile::tempdir().unwrap();
+8 -124
View File
@@ -18,7 +18,6 @@ mod inspect;
pub mod jdtls;
mod launch;
mod library_source;
mod loading;
mod pull;
mod scope;
mod server;
@@ -217,15 +216,6 @@ fn runtime_handle() -> tokio::runtime::Handle {
})
}
fn goto_is_empty(r: &Option<lsp::GotoDefinitionResponse>) -> bool {
match r {
None => true,
Some(lsp::GotoDefinitionResponse::Scalar(_)) => false,
Some(lsp::GotoDefinitionResponse::Array(v)) => v.is_empty(),
Some(lsp::GotoDefinitionResponse::Link(v)) => v.is_empty(),
}
}
fn rpc_err(server: &str, method: &str, e: RpcError) -> IdeError {
match e {
RpcError::Cancelled => IdeError::cancelled(),
@@ -315,27 +305,6 @@ async fn library_sources(snap: &DocSnap, pos: Pos, locs: Vec<Location>) -> Vec<L
.unwrap_or_default()
}
const JAVA_IMPLEMENTATIONS: &str = "openidea.java.implementations";
/// The answer of [`JAVA_IMPLEMENTATIONS`]: `complete` when the type is declared in the
/// sources, so no dependency can implement it.
#[derive(serde::Deserialize)]
struct SourceImplementations {
locations: Vec<lsp::Location>,
complete: bool,
}
/// Receives the implementations found so far, before a slower search for the rest.
type Partial<'a> = Option<&'a (dyn Fn(Vec<Location>) + Send + Sync)>;
/// `first`, then those of `more` not in it (same file and start).
fn merge_locations(first: Vec<Location>, more: Vec<Location>) -> Vec<Location> {
let mut seen: HashSet<(PathBuf, Pos)> = first.iter().map(|l| (l.path.to_path_buf(), l.range.start)).collect();
let mut out = first;
out.extend(more.into_iter().filter(|l| seen.insert((l.path.to_path_buf(), l.range.start))));
out
}
fn goto_method(kind: GotoKind) -> (&'static str, &'static str, &'static str) {
match kind {
GotoKind::Definition => ("textDocument/definition", "definitionProvider", "go to definition"),
@@ -1194,13 +1163,8 @@ impl LspManager {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
let r: IdeResult<Option<lsp::Hover>> =
loading::while_loading(&t.server, Some(&cancel), loading::LOADING_WAIT, Option::is_none, || {
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT)
})
.await;
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await;
match r {
// Still loading: no documentation yet, not an error.
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
Ok(Some(h)) => {
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
return Ok(Some(h));
@@ -1250,22 +1214,7 @@ impl LspManager {
}
let (method, cap, feature) = goto_method(kind);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, None, None).await
}
/// Go to Implementation, with the implementations found first passed to `partial` while
/// the search goes on: jdtls answers from the project's sources quickly, then searches every
/// dependency jar for a type declared in one. Returns them all, the first ones first.
pub async fn goto_implementation(
&self,
doc: DocId,
pos: Pos,
cancel: CancellationToken,
partial: &(dyn Fn(Vec<Location>) + Send + Sync),
) -> IdeResult<Vec<Location>> {
let (method, cap, feature) = goto_method(GotoKind::Implementation);
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
self.goto_in(&snap, &targets, method, pos, Some(&cancel), Some(partial)).await
self.goto_in(&snap, &targets, method, pos, None).await
}
/// Implementations of the symbols at each of `positions` (gutter line
@@ -1282,7 +1231,7 @@ impl LspManager {
let (snap, targets) = self.doc_targets(doc, feature, |c| c.has(cap)).await?;
let requests = positions.iter().copied().map(|pos| {
let (snap, targets, cancel) = (&snap, &targets, &cancel);
async move { self.goto_in(snap, targets, method, pos, Some(cancel), None).await }
async move { self.goto_in(snap, targets, method, pos, Some(cancel)).await }
});
let results: Vec<IdeResult<Vec<Location>>> =
futures::stream::iter(requests).buffered(IMPLEMENTATION_CONCURRENCY).collect().await;
@@ -1295,46 +1244,6 @@ impl LspManager {
Ok(results.into_iter().map(Result::unwrap_or_default).collect())
}
/// jdtls: the implementations in the project's sources (`openidea.java.implementations`),
/// and whether they are all there are. Its standard request builds the type hierarchy over
/// every dependency jar too, which takes long in a project with many dependencies.
async fn java_source_implementations(
&self,
snap: &DocSnap,
t: &Target,
pos: Pos,
cancel: Option<&CancellationToken>,
) -> (Vec<Location>, bool) {
if t.server.name != "jdtls" || !t.caps().commands().iter().any(|c| c == JAVA_IMPLEMENTATIONS) {
return (Vec::new(), false);
}
let params = snap.pos_params(t, pos);
let arguments =
json!([params["textDocument"]["uri"], params["position"]["line"], params["position"]["character"]]);
let result: IdeResult<SourceImplementations> = self
.call(
t,
"workspace/executeCommand",
json!({ "command": JAVA_IMPLEMENTATIONS, "arguments": arguments }),
cancel,
DEFAULT_TIMEOUT,
)
.await;
match result {
Ok(found) => {
let locs =
convert::goto_response(&self.conv(t.enc()), lsp::GotoDefinitionResponse::Array(found.locations));
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
(locs, found.complete)
}
Err(e) => {
tracing::debug!("{JAVA_IMPLEMENTATIONS}: {}", e.message);
(Vec::new(), false)
}
}
}
async fn goto_in(
&self,
snap: &DocSnap,
@@ -1342,52 +1251,29 @@ impl LspManager {
method: &str,
pos: Pos,
cancel: Option<&CancellationToken>,
partial: Partial<'_>,
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
// Found in the sources before the standard request searches the dependencies too.
let mut first = Vec::new();
if method == "textDocument/implementation" {
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
if complete && !locs.is_empty() {
return Ok(locs);
}
if let Some(partial) = partial
&& !locs.is_empty()
{
partial(locs.clone());
first = locs;
}
}
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
match self
.call::<_, Option<lsp::GotoDefinitionResponse>>(
t,
method,
snap.pos_params(t, pos),
cancel,
DEFAULT_TIMEOUT,
)
})
.await;
match r {
.await
{
Ok(Some(r)) => {
let locs = convert::goto_response(&self.conv(t.enc()), r);
let locs = library_sources(snap, pos, locs).await;
self.note_virtual(&t.server, locs.iter().map(|l| l.uri.as_deref()));
let locs = merge_locations(first, locs);
if !locs.is_empty() {
return Ok(locs);
}
out.value = Some(Vec::new());
}
Ok(None) if !first.is_empty() => return Ok(first),
Ok(None) => out.value = Some(Vec::new()),
// Those shown stay valid when the rest can't be found.
Err(e) if !first.is_empty() && e.kind != ErrorKind::Cancelled => {
tracing::debug!("go to implementation in the dependencies: {}", e.message);
return Ok(first);
}
Err(e) => out.fail(e),
}
}
@@ -1571,9 +1457,7 @@ impl LspManager {
return Vec::new();
}
let defining = self.ready_targets(&snap.servers, |c| c.has("definitionProvider")).await;
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel), None)
.await
.unwrap_or_default()
self.goto_in(&snap, &defining, "textDocument/definition", pos, Some(&cancel)).await.unwrap_or_default()
};
let (results, declarations) = futures::join!(join_all(requests), declarations);
let mut out = Outcome::new();
-76
View File
@@ -1,76 +0,0 @@
//! Requests answered while a server is still loading the project.
//!
//! rust-analyzer answers navigation with nothing until it has read the project's
//! files, misses macro-generated items until build scripts and proc macros are
//! built, and withdraws answers (`ContentModified`) when loading changes its
//! data. Such an answer is not final: the request runs again as loading
//! progresses, so ⌘B right after opening a project lands once the server can
//! answer instead of reporting "not found". Only servers that report loading
//! through `experimental/serverStatus` wait; for others nothing changes.
use crate::server::Server;
use ide_core::error::ErrorKind;
use ide_core::{CancellationToken, IdeError, IdeResult};
use std::future::Future;
use std::time::Duration;
/// How long an explicit request waits for a server that is still loading.
pub(crate) const LOADING_WAIT: Duration = Duration::from_secs(30);
/// Retry interval while it loads, for phases that end without a notification.
const POLL: Duration = Duration::from_secs(3);
/// Runs `query` on `server`. While the server is loading, an empty answer or a
/// withdrawn one (`ContentModified`; not a cancellation of `cancel`) runs again
/// whenever a loading phase ends, until it answers, loading ends, or `wait`
/// passes; then a still-loading server yields a `TimedOut` error saying so.
pub(crate) async fn while_loading<R, F, Fut>(
server: &Server,
cancel: Option<&CancellationToken>,
wait: Duration,
is_empty: impl Fn(&R) -> bool,
query: F,
) -> IdeResult<R>
where
F: Fn() -> Fut,
Fut: Future<Output = IdeResult<R>>,
{
let cancelled = || cancel.is_some_and(CancellationToken::is_cancelled);
let deadline = tokio::time::Instant::now() + wait;
loop {
let phase = server.phase_notified();
tokio::pin!(phase);
phase.as_mut().enable();
let asked_while_loading = server.is_loading();
let result = query().await;
let retry = match &result {
Ok(r) => is_empty(r),
Err(e) => e.kind == ErrorKind::Cancelled && !cancelled(),
};
if !retry || cancelled() || server.running().is_none() {
return result;
}
if !server.is_loading() {
// Loading ended while the server answered from its old state: ask once more.
if asked_while_loading {
continue;
}
return result;
}
let now = tokio::time::Instant::now();
if now >= deadline {
return Err(IdeError::new(ErrorKind::TimedOut, format!("{} is still loading the project", server.name)));
}
let until = deadline.min(now + POLL);
let stop = async {
match cancel {
Some(c) => c.cancelled().await,
None => std::future::pending().await,
}
};
tokio::select! {
() = phase => {}
() = tokio::time::sleep_until(until) => {}
() = stop => return Err(IdeError::cancelled()),
}
}
}
+2 -36
View File
@@ -110,11 +110,6 @@ pub struct Server {
pub published_notify: Notify,
/// Woken when the last `$/progress` task ends.
idle_notify: Notify,
/// `experimental/serverStatus` (rust-analyzer): `None` until the server sends
/// one, then whether it is still loading the project.
loading: Mutex<Option<bool>>,
/// Woken whenever a `$/progress` task ends or the server's loading state changes.
phase_notify: Notify,
transient: Mutex<HashMap<PathBuf, Transient>>,
pub java_build_lock: Arc<tokio::sync::Mutex<()>>,
pub java_autobuild_suspended: std::sync::atomic::AtomicBool,
@@ -149,8 +144,6 @@ impl Server {
published: Mutex::new(HashMap::new()),
published_notify: Notify::new(),
idle_notify: Notify::new(),
loading: Mutex::new(None),
phase_notify: Notify::new(),
transient: Mutex::new(HashMap::new()),
java_build_lock: Arc::new(tokio::sync::Mutex::new(())),
java_autobuild_suspended: std::sync::atomic::AtomicBool::new(false),
@@ -396,48 +389,22 @@ impl Server {
if self.running().is_none() {
return;
}
let busy = self.is_busy();
let busy = !self.progress.lock().is_empty();
self.set_status(sink, if busy { ServerStatus::Busy } else { ServerStatus::Ready }, None);
}
/// Whether the server reports work in progress (indexing, analysis).
pub fn is_busy(&self) -> bool {
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
}
/// Whether the server says it is still loading the project, so an empty
/// answer may not be final. Only `serverStatus` tells: `$/progress` also
/// covers background work (indexing, builds) during which answers are final.
pub fn is_loading(&self) -> bool {
*self.loading.lock() == Some(true)
}
/// Records `experimental/serverStatus`: `quiescent` false while the project loads.
pub fn set_loading(&self, sink: &SharedSink, loading: bool) {
if self.loading.lock().replace(loading) == Some(loading) {
return;
}
self.refresh_busy_status(sink);
self.phase_ended();
!self.progress.lock().is_empty()
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
self.phase_ended();
}
fn phase_ended(&self) {
self.phase_notify.notify_waiters();
if !self.is_busy() {
self.idle_notify.notify_waiters();
}
}
/// Woken when the next loading phase ends.
pub fn phase_notified(&self) -> tokio::sync::futures::Notified<'_> {
self.phase_notify.notified()
}
/// Waits until no `$/progress` task is active; false on timeout or cancellation.
pub async fn wait_idle(&self, timeout: Duration, cancel: &CancellationToken) -> bool {
let deadline = tokio::time::Instant::now() + timeout;
@@ -532,7 +499,6 @@ impl Server {
}
pub fn take_progress(&self) -> Vec<(TaskId, String)> {
*self.loading.lock() = None;
self.progress.lock().drain().map(|(_, p)| (p.task, p.title)).collect()
}
+2 -148
View File
@@ -954,11 +954,6 @@ async fn apply_edit_hook_and_server_requests() {
assert_eq!(v, Value::Null);
assert!(peer.request::<_, Value>("unknown/method", Value::Null, None).await.is_err());
peer.notify(
"window/showMessage",
json!({ "type": 1, "message": "overly long loop turn took 113ms (event handling took 95ms): PrimeCaches" }),
)
.unwrap();
peer.notify("window/showMessage", json!({ "type": 2, "message": "careful" })).unwrap();
peer.notify("window/showMessage", json!({ "type": 3, "message": "info only" })).unwrap();
peer.notify("window/logMessage", json!({ "type": 1, "message": "log" })).unwrap();
@@ -967,10 +962,7 @@ async fn apply_edit_hook_and_server_requests() {
})
.await;
let notifies = h.events.lock().iter().filter(|e| matches!(e, IdeEvent::Notify { .. })).count();
assert_eq!(
notifies, 2,
"showMessageRequest error + showMessage warning; info and rust-analyzer's developer notices are not surfaced"
);
assert_eq!(notifies, 2, "showMessageRequest error + showMessage warning; info is not surfaced");
// Dynamically registered capability is now usable.
s.respond(
@@ -1017,78 +1009,6 @@ async fn progress_tasks_and_busy_status() {
assert!(tasks[2].3);
}
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
#[tokio::test(flavor = "multi_thread")]
async fn navigation_waits_for_a_server_that_is_still_loading() {
#[derive(Clone, Copy, Debug)]
enum Answer {
Empty,
ContentModified,
}
for answer in [Answer::Empty, Answer::ContentModified] {
let h = harness(&[("mock", incremental_caps())]);
let (doc, path) = h.open("a.mk", "fn foo() {}\nfoo();\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
if let Answer::ContentModified = answer {
s.errors.lock().insert("textDocument/definition".into(), (-32801, "content modified".into()));
}
let mgr = h.mgr.clone();
let goto = tokio::spawn(async move { mgr.goto(doc, Pos::new(1, 1), GotoKind::Definition).await });
eventually(|| !s.requested("textDocument/definition").is_empty()).await;
assert!(!goto.is_finished(), "{answer:?}: an answer while loading is not final");
s.errors.lock().clear();
let r = json!({"start":{"line":0,"character":3},"end":{"line":0,"character":6}});
s.respond("textDocument/definition", json!({ "uri": uri_of(&path), "range": r }));
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let defs = tokio::time::timeout(Duration::from_secs(10), goto).await.unwrap().unwrap().unwrap();
assert_eq!(defs, vec![ide_core::Location::file(path.clone(), rng(0, 3, 0, 6))], "{answer:?}");
h.wait_status("mock", ServerStatus::Ready).await;
}
// Hover documentation the same way.
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": false })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let mgr = h.mgr.clone();
let hover = tokio::spawn(async move { mgr.hover(doc, Pos::new(0, 0), Default::default()).await });
eventually(|| !s.requested("textDocument/hover").is_empty()).await;
s.respond("textDocument/hover", json!({ "contents": "docs" }));
s.peer().notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": true })).unwrap();
let hover = tokio::time::timeout(Duration::from_secs(10), hover).await.unwrap().unwrap().unwrap();
assert!(hover.is_some());
// "Nothing here" is the answer at once: rust-analyzer loaded with `cargo check`
// running, and a server without `serverStatus` indexing in the background.
for quiescent in [Some(true), None] {
let h = harness(&[("mock", incremental_caps())]);
let (doc, _) = h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let peer = s.peer();
if let Some(q) = quiescent {
peer.notify("experimental/serverStatus", json!({ "health": "ok", "quiescent": q })).unwrap();
}
peer.notify("$/progress", json!({ "token": "bg", "value": { "kind": "begin", "title": "Indexing" } })).unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
let defs = tokio::time::timeout(Duration::from_secs(2), h.mgr.goto(doc, Pos::new(0, 0), GotoKind::Definition))
.await
.unwrap_or_else(|_| panic!("{quiescent:?}: no wait for background work"))
.unwrap();
assert!(defs.is_empty());
assert_eq!(s.requested("textDocument/definition").len(), 1, "{quiescent:?}");
}
}
#[tokio::test(flavor = "multi_thread")]
async fn progress_titled_with_an_executable_path_shows_the_program_name() {
let h = harness(&[("mock", incremental_caps())]);
@@ -1804,71 +1724,6 @@ async fn implementations_per_position() {
assert_eq!(err.kind, ide_core::error::ErrorKind::Cancelled);
}
#[tokio::test(flavor = "multi_thread")]
async fn java_implementations_come_from_sources_first() {
let caps = json!({
"textDocumentSync": 1,
"implementationProvider": true,
"executeCommandProvider": { "commands": ["openidea.java.implementations"] },
});
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
let (doc, path) = h.open("Repo.java", "interface Repo {}\nclass A implements Repo {}\nclass B {}\nclass C {}\n");
h.wait_status("jdtls", ServerStatus::Ready).await;
let s = h.launcher.state("jdtls");
let uri = uri_of(&path);
let at = move |lines: &[u32]| {
lines
.iter()
.map(|&l| json!({ "uri": uri, "range": {"start":{"line":l,"character":6},"end":{"line":l,"character":7}} }))
.collect::<Value>()
};
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
_ => json!({ "locations": [], "complete": true }),
}),
"textDocument/implementation" => Some(at(&[3, 1])),
_ => None,
}));
let lines = |locs: &[ide_core::Location]| locs.iter().map(|l| l.range.start.line).collect::<Vec<_>>();
let found = h.mgr.goto(doc, Pos::new(0, 10), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![1]);
let markers = h.mgr.implementations(doc, &[Pos::new(0, 10)], Default::default()).await.unwrap();
assert_eq!(lines(&markers[0]), vec![1]);
assert!(s.requested("textDocument/implementation").is_empty(), "no search through the dependencies");
let asked = s.requested("workspace/executeCommand");
assert_eq!(asked[0]["command"], "openidea.java.implementations");
assert_eq!((asked[0]["arguments"][1].clone(), asked[0]["arguments"][2].clone()), (json!(0), json!(10)));
// A dependency's type: those in the sources first, then the dependencies' too.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(2, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert_eq!(*partial.lock(), vec![vec![1]]);
assert_eq!(lines(&found), vec![1, 3]);
assert_eq!(s.requested("textDocument/implementation").len(), 1);
// Without a receiver for the first ones (gutter markers), just all of them.
let found = h.mgr.goto(doc, Pos::new(2, 6), GotoKind::Implementation).await.unwrap();
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 2);
// None in the sources: the standard request, with the dependencies.
let partial = Mutex::new(Vec::new());
let found = h
.mgr
.goto_implementation(doc, Pos::new(3, 6), Default::default(), &|locs| partial.lock().push(lines(&locs)))
.await
.unwrap();
assert!(partial.lock().is_empty());
assert_eq!(lines(&found), vec![3, 1]);
assert_eq!(s.requested("textDocument/implementation").len(), 3);
}
#[tokio::test(flavor = "multi_thread")]
async fn navigation_rename_format_and_decorations() {
let h = harness(&[("mock", incremental_caps())]);
@@ -3478,8 +3333,7 @@ async fn interactive_capability_is_scoped_to_gopls() {
let (doc, _) = h.open("probe.mk", "x\n");
h.wait_status("mock", ServerStatus::Ready).await;
let s = h.launcher.state("mock");
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
let form = h
.mgr
.resolve_command(
+3 -3
View File
@@ -9,7 +9,7 @@ use std::{
time::{Duration, Instant},
};
use ide_core::{IdeError, IdeResult, IpcPath, error::ErrorKind};
use ide_core::{IdeError, IdeResult, error::ErrorKind};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use specta::Type;
@@ -22,7 +22,7 @@ use crate::kernel::KernelOutput;
#[serde(rename_all = "camelCase")]
pub struct NotebookSnapshot {
pub id: String,
pub path: IpcPath,
pub path: String,
pub version: u32,
pub dirty: bool,
pub can_undo: bool,
@@ -188,7 +188,7 @@ impl NotebookDocument {
pub fn snapshot(&self) -> NotebookSnapshot {
NotebookSnapshot {
id: self.id.clone(),
path: self.path.clone().into(),
path: self.path.to_string_lossy().into_owned(),
version: self.version,
dirty: self.is_dirty(),
can_undo: !self.execution_active && !self.undo.is_empty(),
+7 -24
View File
@@ -13,7 +13,6 @@ use crate::types::{
};
use crate::wit_host::{bindings, providers_bindings, syntax_bindings, views_bindings};
use ide_core::CancellationToken;
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use parking_lot::Mutex;
use std::path::Path;
@@ -936,7 +935,12 @@ fn location_from_wit(l: wit_types::Location) -> Location {
}
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
wit_prov::Document {
path: d.path.to_string_lossy().into_owned(),
language: d.language.clone(),
version: d.version,
text: d.text.clone(),
}
}
/// Resets the budgets for the next call: fuel, deadline, cancellation.
@@ -1008,7 +1012,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
.edits
.iter()
.map(|(path, edits)| wit_prov::FileEdit {
path: to_ipc(path),
path: path.to_string_lossy().into_owned(),
edits: edits
.iter()
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
@@ -1127,27 +1131,6 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
mod tests {
use super::*;
#[cfg(windows)]
#[test]
fn provider_and_resolved_action_paths_match_ipc_events() {
for (path, expected) in [
(r"C:\project\Example.java", "C:/project/Example.java"),
(r"\\?\C:\project\Example.java", "C:/project/Example.java"),
(r"\\server\share\Example.java", "//server/share/Example.java"),
] {
let doc = ProviderDocument { path: path.into(), ..Default::default() };
assert_eq!(doc_to_wit(&doc).path, expected);
let action = CodeAction {
title: "Fix".into(),
kind: None,
preferred: false,
edits: vec![(path.into(), Vec::new())],
data: None,
};
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
}
}
fn ext(kind: &str) -> wit_types::Extension {
wit_types::Extension { kind: kind.into(), data: "{}".into() }
}
+2 -4
View File
@@ -5,7 +5,6 @@
use crate::runtime::{StoreData, clip};
use crate::sandbox;
use crate::types::{EditorContext, EventKind, HostError, HttpRequest, NotificationLevel, StatusItem, net_host};
use ide_core::ipc_path::to_ipc;
use ide_extension::Capability;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
@@ -311,8 +310,7 @@ impl workspace::HostProject for StoreData {
if let Err(e) = self.require(Capability::ProjectRead) {
return Ok(Err(e));
}
// Paths reach plugins in IPC form (`/` on Windows), as the UI's event paths do, so they compare as text.
Ok(Ok(to_ipc(&self.table.get(&this)?.root)))
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
}
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
@@ -348,7 +346,7 @@ impl editor::Host for StoreData {}
impl editor::HostTextEditor for StoreData {
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
}
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
+1 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "ide-remote"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)"
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -10,7 +10,6 @@ ide-core.workspace = true
ide-ssh.workspace = true
ide-run.workspace = true
serde.workspace = true
serde_json.workspace = true
specta.workspace = true
tokio.workspace = true
tracing.workspace = true
-347
View File
@@ -1,347 +0,0 @@
//! Run configurations on Docker and Compose run targets: rewritten into a local
//! `docker run --rm` / `docker compose run --rm` of the configuration's program,
//! with the project bind-mounted and its paths and interpreters swapped for the
//! container's. The console, stdin, Stop and test parsers then work as for any
//! local run.
use crate::target::remote_command;
use ide_core::remote_settings::{RunTarget, RunTargetKind};
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
use std::collections::BTreeMap;
use std::path::Path;
/// What the rewrite needs besides the configuration and its target.
pub struct ContainerRun<'a> {
pub target: &'a RunTarget,
/// The project on this machine.
pub root: &'a Path,
/// Where the project is in the container.
pub container_root: &'a str,
/// The docker executable.
pub docker: &'a str,
/// The connection's variables for the docker CLI (`ide_docker::run_env`).
pub endpoint_env: &'a BTreeMap<String, String>,
/// Variables of the configuration's `.env` files, already read.
pub file_env: &'a BTreeMap<String, String>,
/// Container name, unique per run.
pub name: &'a str,
/// `--user uid:gid`; `None` keeps the image's user.
pub user: Option<(u32, u32)>,
}
/// The container folder of the project when a Docker target sets none.
pub fn default_container_root(root: &Path) -> String {
let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let name: String =
name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect();
format!("/workspaces/{}", if name.is_empty() { "project" } else { &name })
}
/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`.
pub fn container_name(config_id: &str, unique: &str) -> String {
let id: String = config_id
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' })
.take(40)
.collect();
format!("openidea-{}-{unique}", id.trim_matches('-'))
}
/// `config` as a local run of the docker CLI that runs it in a new container.
pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult<RunConfig> {
let target = run.target;
let command = remote_command(config, target, run.root, run.container_root);
// Variables go by name (`-e NAME`) so their values stay out of the command line
// echoed in the console; the docker CLI reads them from its own environment.
let mut env: BTreeMap<String, String> = run.file_env.clone();
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test")
&& !command.env.contains_key("PYTHONUNBUFFERED")
{
env.insert("PYTHONUNBUFFERED".into(), "1".into());
}
env.extend(command.env.clone());
let pty = config.extras.emulate_terminal;
let mut args: Vec<String> = Vec::new();
match target.kind {
RunTargetKind::Docker => {
if target.image.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name)));
}
args.extend(["run", "--rm", "--interactive", "--init"].map(String::from));
if pty {
args.push("--tty".into());
}
let root = run.root.to_string_lossy();
args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]);
}
RunTargetKind::Compose => {
if target.service.trim().is_empty() {
return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name)));
}
args.push("compose".into());
for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) {
args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]);
}
args.extend(["run", "--rm"].map(String::from));
if !pty {
args.push("--no-TTY".into());
}
}
RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")),
}
args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]);
args.extend(["--workdir".into(), command.cwd.clone()]);
let mut docker_env = run.endpoint_env.clone();
for (key, value) in &env {
if run.endpoint_env.contains_key(key) {
// `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's.
args.extend(["--env".into(), format!("{key}={value}")]);
} else {
args.extend(["--env".into(), key.clone()]);
docker_env.insert(key.clone(), value.clone());
}
}
for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) {
args.extend(["--publish".into(), port.to_owned()]);
}
if let Some((uid, gid)) = run.user {
args.extend(["--user".into(), format!("{uid}:{gid}")]);
}
let options = target.run_options.trim();
if !options.is_empty() {
args.extend(ide_run::import::split_args(options));
}
args.push(match target.kind {
RunTargetKind::Compose => target.service.trim().to_owned(),
_ => target.image.trim().to_owned(),
});
match command.shell_line {
Some(line) => args.extend(["sh".into(), "-c".into(), line]),
None => {
args.push(command.program);
args.extend(command.args);
}
}
let mut out = config.clone();
out.command = run.docker.to_owned();
out.args = args;
out.cwd = Some(run.root.to_path_buf().into());
out.env = docker_env;
let extras = &mut out.extras;
extras.target = None;
extras.env_files.clear();
// The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`.
extras.include_system_env = true;
// `docker stop` signals the container's process (the CLI may not forward a signal).
extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name));
Ok(out)
}
/// `word` for this machine's shell (the stop command runs there): the docker CLI found
/// outside `PATH` (Docker.app's bundle) may contain spaces.
fn shell_word(word: &str) -> String {
if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() {
word.to_owned()
} else if cfg!(windows) {
format!("\"{word}\"")
} else {
format!("'{}'", word.replace('\'', "'\\''"))
}
}
/// The project's folder in the container of a Compose `service`, from the JSON of
/// `docker compose config --format json`: the target of a bind mount of the project
/// or a folder above it.
pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult<String> {
let model: serde_json::Value = serde_json::from_str(config_json)
.map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?;
let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else {
return Err(IdeError::not_found(format!("the Compose files have no service '{service}'")));
};
let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten();
let mut best: Option<(usize, String)> = None;
for m in mounts {
if m.get("type").and_then(|t| t.as_str()) != Some("bind") {
continue;
}
let (Some(source), Some(target)) =
(m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str()))
else {
continue;
};
let Ok(rel) = root.strip_prefix(source) else { continue };
let depth = Path::new(source).components().count();
if best.as_ref().is_none_or(|(d, _)| depth > *d) {
let rel = crate::mapping::posix(rel);
let target = target.trim_end_matches('/');
let folder = match (target.is_empty(), rel.is_empty()) {
(true, true) => "/".to_owned(),
(_, true) => target.to_owned(),
_ => format!("{target}/{rel}"),
};
best = Some((depth, folder));
}
}
best.map(|(_, folder)| folder).ok_or_else(|| {
IdeError::invalid(format!(
"service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn config(command: &str, args: &[&str]) -> RunConfig {
RunConfig {
id: "app".into(),
name: "app".into(),
kind: "python".into(),
command: command.into(),
args: args.iter().map(|s| s.to_string()).collect(),
cwd: None,
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
detected: false,
is_test: false,
debug_adapter: None,
allow_parallel: false,
extras: Default::default(),
}
}
fn run<'a>(
target: &'a RunTarget,
endpoint: &'a BTreeMap<String, String>,
files: &'a BTreeMap<String, String>,
) -> ContainerRun<'a> {
ContainerRun {
target,
root: Path::new("/work/proj"),
container_root: "/workspaces/proj",
docker: "/usr/local/bin/docker",
endpoint_env: endpoint,
file_env: files,
name: "openidea-app-1",
user: Some((501, 20)),
}
}
// Unix paths are not absolute on Windows.
#[cfg(unix)]
#[test]
fn docker_target_runs_the_program_in_a_new_container() {
let target = RunTarget {
kind: RunTargetKind::Docker,
name: "py".into(),
image: "python:3.12".into(),
ports: vec!["8000:8000".into(), " ".into()],
run_options: r#"--network host --label "a b""#.into(),
..Default::default()
};
let endpoint = BTreeMap::from([
("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()),
("PATH".to_owned(), "/usr/local/bin".to_owned()),
]);
let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]);
let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]);
c.env.insert("PATH".into(), "/opt/bin".into());
c.extras.target = Some("t".into());
c.extras.env_files = vec![".env".into()];
let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap();
assert_eq!(out.command, "/usr/local/bin/docker");
assert_eq!(
out.args.join(" "),
"run --rm --interactive --init --volume /work/proj:/workspaces/proj \
--name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \
--env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \
--publish 8000:8000 --user 501:20 --network host --label a b \
python:3.12 python3 -u /workspaces/proj/main.py"
);
assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped");
assert_eq!(out.env["SECRET"], "s3");
assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH");
assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack");
assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj")));
assert_eq!(out.extras.target, None);
assert!(out.extras.env_files.is_empty() && out.extras.include_system_env);
assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1"));
assert_eq!(out.kind, "python", "test and output parsers still apply");
let mut c = config("npm run dev", &[]);
c.kind = "npm".into();
c.extras.emulate_terminal = true;
let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() };
let mut r = run(&target, &endpoint, &files);
r.user = None;
let out = container_config(&c, &r).unwrap();
assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from)));
assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string()));
assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from)));
let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image"));
}
#[cfg(unix)]
#[test]
fn compose_target_runs_in_a_new_container_of_the_service() {
let target = RunTarget {
kind: RunTargetKind::Compose,
compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()],
service: "web".into(),
..Default::default()
};
let none = BTreeMap::new();
let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]);
c.kind = "django-test".into();
c.cwd = Some(PathBuf::from("/work/proj/backend").into());
let mut r = run(&target, &none, &none);
r.container_root = "/app";
let out = container_config(&c, &r).unwrap();
assert_eq!(
out.args.join(" "),
"compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \
--name openidea-app-1 --label openidea.run=app --workdir /app/backend \
--env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test"
);
assert_eq!(out.env["DATA"], "/app/data");
let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() };
assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service"));
}
#[cfg(unix)]
#[test]
fn compose_container_root_follows_the_project_bind_mount() {
let json = r#"{"services": {
"web": {"volumes": [
{"type": "volume", "source": "data", "target": "/data"},
{"type": "bind", "source": "/work", "target": "/src"},
{"type": "bind", "source": "/work/proj", "target": "/app/"}
]},
"worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]},
"db": {}
}}"#;
let root = Path::new("/work/proj");
assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins");
assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj");
assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount"));
assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound);
}
#[test]
fn names_and_default_folder() {
assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x");
assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj");
}
#[cfg(unix)]
#[test]
fn stop_quotes_the_docker_cli_for_the_shell() {
assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker");
assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'");
}
}
+1 -2
View File
@@ -1,8 +1,7 @@
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]).
//! configurations on run targets ([`target`]).
pub mod deploy;
pub mod docker;
pub mod import;
pub mod mapping;
pub mod service;
-3
View File
@@ -338,9 +338,6 @@ impl RemoteService {
) -> IdeResult<RunInfo> {
let target_id = config.extras.target.as_deref().unwrap_or_default();
let target = run_target(settings, target_id)?;
if target.kind != ide_core::remote_settings::RunTargetKind::Ssh {
return Err(IdeError::internal("container run targets start as local runs of the docker CLI"));
}
let session = self.ssh.session(&target.ssh_config).await?;
let fs = RemoteFs::new(session.sftp().await?);
let folder = if target.project_root.trim().is_empty() {
-15
View File
@@ -1059,10 +1059,6 @@ fn ruby_config(
c
}
/// Loads the test files of the directories (or files) named in `ARGV`, default `test`, the way `rake test`
/// does; options such as `-v` stay in `ARGV` for Minitest.
const MINITEST_RUNNER: &str = r#"paths, opts = ARGV.partition { |a| !a.start_with?("-") }; ARGV.replace(opts); (paths.empty? ? ["test"] : paths).flat_map { |d| File.file?(d) ? [d] : Dir[File.join(d, "**", "{*_test,test_*}.rb")] }.sort.each { |f| require File.expand_path(f) }"#;
fn uses_rspec(root: &Path) -> bool {
root.join("spec").is_dir()
|| root.join(".rspec").is_file()
@@ -1105,17 +1101,6 @@ fn detect_ruby(root: &Path, out: &mut Vec<RunConfig>) {
}
if uses_rspec(root) {
out.push(ruby_config("rspec".into(), "rspec".into(), "rspec", ruby_tool(root, "rspec"), &[]).test());
} else if root.join("test").is_dir() && !out.iter().any(|c| c.id == "rake:test") {
// Minitest / Test::Unit without a `test` Rake task: load every test file under `test/`.
let mut args = Vec::new();
for dir in ["lib", "test"] {
if root.join(dir).is_dir() {
args.push(format!("-I{dir}"));
}
}
args.extend(["-e".to_owned(), MINITEST_RUNNER.to_owned(), "test".to_owned()]);
let extra: Vec<&str> = args.iter().map(String::as_str).collect();
out.push(ruby_config("minitest".into(), "minitest".into(), "ruby", ruby_tool(root, "ruby"), &extra).test());
}
}
+2 -105
View File
@@ -3,9 +3,8 @@
//! its templates (`default="true"`) and temporary configurations. A configuration found in more
//! than one place is taken from the first, in that order.
//!
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script,
//! Application, Spring Boot and compound configurations become the matching kinds; other types
//! are skipped with their type.
//! Python, Django server, Django tests, pytest, npm, Jest, Vitest, Node.js, Shell Script and
//! compound configurations become the matching kinds; other types are skipped with their type.
//! `$PROJECT_DIR$` (and `$MODULE_DIR$`, `$MODULE_WORKING_DIR$`, `$ContentRoot$`,
//! `$ProjectFileDir$`, all the project directory here) and `$USER_HOME$` are replaced; another
//! path variable skips the configuration.
@@ -163,8 +162,6 @@ const VITEST: &str = "JavaScriptTestRunnerVitest";
const NODE: &str = "NodeJSConfigurationType";
const SHELL: &str = "ShConfigurationType";
const COMPOUND: &str = "CompoundRunConfigurationType";
const APPLICATION: &str = "Application";
const SPRING_BOOT: &str = "SpringBootApplicationConfigurationType";
type Res<T> = Result<T, SkipReason>;
@@ -332,8 +329,6 @@ impl Cx<'_> {
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
NODE => self.node(name, c, &mut ignored)?,
SHELL => self.shell(name, c)?,
APPLICATION => self.java_app(name, c, false, &mut ignored)?,
SPRING_BOOT => self.java_app(name, c, true, &mut ignored)?,
COMPOUND => {
members = c.children_named("toRun").filter_map(|t| t.attr("name")).map(str::to_owned).collect();
config(name, "compound", String::new(), Vec::new(), None)
@@ -700,54 +695,6 @@ impl Cx<'_> {
Ok(rc)
}
// ------------------------------------------------------------- Java
/// IDEA's Application and Spring Boot types, as the `application` / `spring-boot` forms run
/// them: `java <vm options> -cp ${classpath} <main> [--spring.profiles.active=…] <arguments>`
/// with the module's runtime classpath.
fn java_app(&self, name: &str, c: &El, spring: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
let main = if spring { "SPRING_BOOT_MAIN_CLASS" } else { "MAIN_CLASS_NAME" };
let Some(main) = self.text(c.option(main))? else { return Err(SkipReason::NothingToRun) };
let (_, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
let mut args = self.words(c.option("VM_PARAMETERS"))?;
args.extend(["-cp".to_owned(), "${classpath}".to_owned(), main]);
if spring && let Some(profiles) = self.text(c.option("ACTIVE_PROFILES"))? {
args.push(format!("--spring.profiles.active={profiles}"));
}
args.extend(self.words(c.option("PROGRAM_PARAMETERS"))?);
let mut rc =
config(name, if spring { "spring-boot" } else { "application" }, "java".into(), args, Some("java-debug"));
rc.cwd = cwd.map(Into::into);
self.envs(c, &mut rc);
if let Some(module) = c.child("module").and_then(|m| m.attr("name")).filter(|m| !m.is_empty()) {
match module_dir(self.root, module) {
Some(dir) => rc.extras.module = Some(dir),
None => ignored.push(format!("module {module}")),
}
}
// Options the forms have no field for.
if c.flag("ALTERNATIVE_JRE_PATH_ENABLED") {
ignored.push("JRE".into());
}
if c.flag("INCLUDE_PROVIDED_SCOPE") {
ignored.push("provided scope".into());
}
if c.child("classpathModifications").is_some_and(|m| !m.children.is_empty()) {
ignored.push("classpath modifications".into());
}
if spring {
for (option, label) in [("HIDE_BANNER", "hide banner"), ("DEBUG_MODE", "debug output")] {
if c.flag(option) {
ignored.push(label.into());
}
}
if c.child("additionalParameters").is_some_and(|p| !p.children.is_empty()) {
ignored.push("override configuration properties".into());
}
}
Ok(rc)
}
// ------------------------------------------------------------- Shell
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
@@ -888,56 +835,6 @@ fn module_count(root: &Path) -> usize {
.sum()
}
/// The folder (relative, `""` for the project's own) of IDEA module `name`: a Maven module is
/// named after its `artifactId` (usually its folder), a Gradle one `<project>.<path>.<source set>`.
fn module_dir(root: &Path, name: &str) -> Option<String> {
let folder = |dir: &Path| dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
let exact = |dir: &Path| folder(dir) == name || pom_artifact(dir).is_some_and(|a| a == name);
if let Some(dir) = find_module(root, &exact) {
return Some(dir);
}
let gradle = name.strip_suffix(".main").or_else(|| name.strip_suffix(".test")).unwrap_or(name);
let last = gradle.rsplit('.').next().filter(|last| *last != name)?;
find_module(root, &|dir: &Path| folder(dir) == last)
}
/// The shallowest build folder under `root` (itself included) that `names` accepts.
fn find_module(root: &Path, names: &dyn Fn(&Path) -> bool) -> Option<String> {
const BUILDS: [&str; 3] = ["pom.xml", "build.gradle", "build.gradle.kts"];
let mut level = vec![root.to_path_buf()];
for _ in 0..5 {
if let Some(found) = level.iter().find(|d| BUILDS.iter().any(|b| d.join(b).is_file()) && names(d)) {
let rel = found.strip_prefix(root).ok()?;
return Some(rel.to_string_lossy().replace('\\', "/"));
}
let mut next: Vec<PathBuf> = level
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flat_map(|entries| entries.flatten().map(|e| e.path()))
.filter(|p| p.is_dir())
.filter(|p| {
let n = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
!n.starts_with('.') && !["node_modules", "target", "build", "out", "src"].contains(&n.as_str())
})
.collect();
next.sort();
level = next;
}
None
}
/// The `artifactId` of the module's own `pom.xml` (not its parent's).
fn pom_artifact(dir: &Path) -> Option<String> {
let pom = std::fs::read_to_string(dir.join("pom.xml")).ok()?;
let own = match (pom.find("<parent>"), pom.find("</parent>")) {
(Some(start), Some(end)) if start < end => format!("{}{}", &pom[..start], &pom[end..]),
_ => pom,
};
let start = own.find("<artifactId>")? + "<artifactId>".len();
let end = start + own[start..].find("</artifactId>")?;
Some(own[start..end].trim().to_owned())
}
/// `--settings` from PyCharm's "Settings" field: a file (`mysite/settings/dev.py`, relative to
/// the project) becomes its module relative to the working directory; a module stays as it is.
fn settings_module(value: &str, root: &Path, dir: &Path) -> String {
+1 -1
View File
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
/// (like `parseArgs` in `src/platform/commandLine.ts`).
pub fn split_args(line: &str) -> Vec<String> {
pub(crate) fn split_args(line: &str) -> Vec<String> {
let mut out = Vec::new();
let mut cur = String::new();
let mut has = false;
-2
View File
@@ -12,8 +12,6 @@ pub mod java_cmdline;
pub mod junit;
mod process;
pub mod python_path;
pub mod ruby_preload;
pub mod ruby_reporter;
pub mod tomcat;
mod utf8;
pub mod which;
-6
View File
@@ -80,8 +80,6 @@ pub struct StartOptions {
pub encoding: Option<String>,
/// Project content and source roots added to Python launch environments.
pub python_paths: Vec<PathBuf>,
/// Where the Ruby test reporter is installed (see [`crate::ruby_reporter`]); Ruby test runs load it.
pub ruby_reporter: Option<PathBuf>,
}
#[derive(Default)]
@@ -357,10 +355,6 @@ impl ProcessManager {
if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") {
crate::python_path::extend(&mut env.vars, &options.python_paths, !env.clear)?;
}
if let Some(dir) = options.ruby_reporter.as_deref().filter(|_| crate::ruby_reporter::applies(config)) {
crate::ruby_preload::add(&mut env.vars, dir, crate::ruby_reporter::FEATURE, !env.clear);
env.vars.insert("OPENIDEA_TEST_REPORT".into(), "1".into());
}
let on_output: OutputFn = Arc::from(on_output);
let on_output = match extras.output_file.as_deref() {
Some(file) => tee_output(&resolve_cwd(root, Some(Path::new(file))), on_output)?,
-61
View File
@@ -1,61 +0,0 @@
//! Loads a Ruby file into a launched process without touching the project: the directory goes on
//! `RUBYLIB` and `RUBYOPT` gets the bare feature name (`-ropenidea_hook`).
//!
//! `RUBYOPT` is split at spaces with no quoting, so an absolute path in it (`-r/…/Application
//! Support/…/hook.rb`, where the IDE keeps its data) makes Ruby fail with `invalid switch in RUBYOPT`.
//! `RUBYLIB` is a path list and takes spaces.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
/// Makes `feature` (a file `<dir>/<feature>.rb`) load first in every Ruby the process starts.
/// `inherit`: the process also sees the IDE's own environment, whose `RUBYLIB` / `RUBYOPT` stay.
pub fn add(env: &mut BTreeMap<String, String>, dir: &Path, feature: &str, inherit: bool) {
let mut inherited = if inherit { crate::system_env() } else { BTreeMap::new() };
let mut existing = |name: &str| env.get(name).cloned().or_else(|| inherited.remove(name)).filter(|v| !v.is_empty());
let mut dirs: Vec<PathBuf> = vec![dir.to_path_buf()];
if let Some(lib) = existing("RUBYLIB") {
for d in std::env::split_paths(&lib) {
if !dirs.contains(&d) {
dirs.push(d);
}
}
}
let rubyopt = existing("RUBYOPT");
if let Ok(joined) = std::env::join_paths(dirs) {
env.insert("RUBYLIB".into(), joined.to_string_lossy().into_owned());
}
let option = format!("-r{feature}");
let value = match rubyopt {
Some(e) if e.split_whitespace().any(|o| o == option) => e,
Some(e) => format!("{e} {option}"),
None => option,
};
env.insert("RUBYOPT".into(), value);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_directory_with_spaces_goes_on_rubylib_not_into_rubyopt() {
let dir = Path::new("/Users/me/Library/Application Support/ide/ruby");
let mut env = BTreeMap::new();
add(&mut env, dir, "openidea_hook", false);
assert_eq!(env["RUBYLIB"], dir.to_string_lossy());
assert_eq!(env["RUBYOPT"], "-ropenidea_hook");
assert!(!env["RUBYOPT"].contains(' '));
}
#[test]
fn keeps_existing_settings_and_adds_once() {
// Path lists use the platform's separator (`;` on Windows).
let list = |dirs: &[&str]| std::env::join_paths(dirs).unwrap().to_string_lossy().into_owned();
let mut env = BTreeMap::from([("RUBYLIB".into(), list(&["/a", "/b"])), ("RUBYOPT".into(), "-w".into())]);
add(&mut env, Path::new("/hook"), "h", false);
add(&mut env, Path::new("/hook"), "h", false);
assert_eq!(env["RUBYLIB"], list(&["/hook", "/a", "/b"]));
assert_eq!(env["RUBYOPT"], "-w -rh");
}
}
-52
View File
@@ -1,52 +0,0 @@
//! A Minitest reporter the IDE loads into test runs (see `ruby_reporter/`): it prints one record per
//! test on stdout, which the IDE strips from the console and turns into the test tree. Tests then
//! report their real source file and line, and the output no longer needs `-v` to be parsed.
use crate::RunConfig;
use std::io;
use std::path::Path;
/// The Ruby file `RUBYOPT` loads first (`<dir>/openidea_test_hook.rb`).
pub const FEATURE: &str = "openidea_test_hook";
const FILES: [(&str, &str); 2] = [
("openidea_test_hook.rb", include_str!("ruby_reporter/openidea_test_hook.rb")),
("openidea/minitest_reporter.rb", include_str!("ruby_reporter/minitest_reporter.rb")),
];
/// Whether a run takes the reporter: Minitest-style test runs through `ruby` or `rake`, with their output
/// in the console (a terminal would show the records).
pub fn applies(config: &RunConfig) -> bool {
config.is_test && matches!(config.kind.as_str(), "ruby" | "rake") && !config.extras.emulate_terminal
}
/// Writes the reporter files under `dir` (unchanged files stay).
pub fn install(dir: &Path) -> io::Result<()> {
for (name, text) in FILES {
let path = dir.join(name);
if std::fs::read_to_string(&path).is_ok_and(|current| current == text) {
continue;
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path, text)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn installs_the_files_once_and_only_for_ruby_test_runs() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path().join("ruby reporter");
install(&dir).unwrap();
install(&dir).unwrap();
for (name, text) in FILES {
assert_eq!(std::fs::read_to_string(dir.join(name)).unwrap(), text);
}
assert!(FILES[0].1.contains("OPENIDEA_TEST_REPORT") && FILES[1].1.contains("@@OPENIDEA@@"));
}
}
@@ -1,78 +0,0 @@
# Written by OpenIDEA for test runs; registered by `openidea_test_hook.rb` once Minitest is loaded.
# Nothing in the project is changed.
#
# One record per line on stdout, which the IDE strips from the console again:
# @@OPENIDEA@@{"t":"start","c":"MyTest","n":"test_x","f":"/abs/test.rb","l":12}
# @@OPENIDEA@@{"t":"result","c":"MyTest","n":"test_x","s":"passed","d":0.001,"f":...,"l":...,"m":"failure text"}
# @@OPENIDEA@@{"t":"summary","runs":7,"failures":1,"errors":1,"skips":1,"d":0.4}
require "json"
module Minitest
class OpenIdeaReporter < AbstractReporter
MARK = "@@OPENIDEA@@"
def initialize(io)
super()
@io = io
@cwd = Dir.pwd
@lock = Mutex.new
@runs = @failures = @errors = @skips = 0
@started = nil
end
def start
@started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def prerecord(klass, name)
emit(t: "start", c: klass.to_s, n: name.to_s, **where(klass, name))
end
def record(result)
@runs += 1
status = result.skipped? ? "skipped" : result.passed? ? "passed" : "failed"
@skips += 1 if status == "skipped"
if status == "failed"
result.failures.any? { |f| f.is_a?(UnexpectedError) } ? @errors += 1 : @failures += 1
end
loc = result.source_location
emit(t: "result", c: result.klass.to_s, n: result.name.to_s, s: status, d: result.time,
f: loc && absolute(loc[0]), l: loc && loc[1], m: result.failures.map(&:message).join("\n"))
end
def report
d = @started ? Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started : nil
emit(t: "summary", runs: @runs, failures: @failures, errors: @errors, skips: @skips, d: d)
end
private
# Where the test is defined, known before it runs (so the tree can show it while running).
def where(klass, name)
f, l = klass.instance_method(name).source_location
{ f: absolute(f), l: l }
rescue StandardError
{}
end
# Test files are often loaded by a path relative to the working directory.
def absolute(file)
File.expand_path(file, @cwd)
end
def emit(record)
line = "#{MARK}#{JSON.generate(record)}\n"
@lock.synchronize { @io.write(line) }
rescue StandardError
nil # reporting must never fail a test run
end
end
def self.plugin_openidea_init(options)
return unless ENV["OPENIDEA_TEST_REPORT"]
io = options[:io] || $stdout
io.sync = true if io.respond_to?(:sync=)
reporter << OpenIdeaReporter.new(io)
end
end
@@ -1,32 +0,0 @@
# Written by OpenIDEA for test runs. Loaded first in every Ruby the run starts
# (RUBYLIB=<this directory>, RUBYOPT=-ropenidea_test_hook).
#
# Registers the test reporter once a test framework is loaded. Minitest 5 would find a plugin on
# its own, Minitest 6 does not load plugins any more, so both are registered the same way.
if ENV["OPENIDEA_TEST_REPORT"]
module OpenIdeaTestHook
@registered = false
def self.register
return if @registered
return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions) && defined?(::Minitest::AbstractReporter)
@registered = true
require "openidea/minitest_reporter"
::Minitest.extensions << "openidea" unless ::Minitest.extensions.include?("openidea")
rescue StandardError, LoadError
nil # reporting must never break a test run
end
# `require "minitest/autorun"` loads Minitest through require_relative, so look after every require.
module Watch
def require(path)
result = super
OpenIdeaTestHook.register
result
end
end
end
Kernel.prepend(OpenIdeaTestHook::Watch)
end
+5 -234
View File
@@ -71,62 +71,6 @@ pub fn context_xml(doc_base: &Path, reload: bool) -> String {
format!("<Context docBase=\"{}\" reloadable=\"{reload}\" />\n", escape(&doc_base.to_string_lossy()))
}
/// The web app's own `META-INF/context.xml`, from an exploded directory or a WAR.
fn app_context_xml(doc_base: &Path) -> Option<String> {
if doc_base.is_dir() {
return fs::read_to_string(doc_base.join("META-INF/context.xml")).ok();
}
let mut zip = zip::ZipArchive::new(fs::File::open(doc_base).ok()?).ok()?;
let mut text = String::new();
zip.by_name("META-INF/context.xml").ok()?.read_to_string(&mut text).ok()?;
Some(text)
}
/// The context descriptor for `conf/Catalina/localhost`. Tomcat ignores the app's
/// `META-INF/context.xml` once a descriptor exists there, so its content (`<Resource>`,
/// `<Environment>`, `<Valve>`…) is carried over, with `docBase` and `reloadable` set by the IDE.
pub fn deployed_context_xml(doc_base: &Path, reload: bool) -> String {
let Some(app) = app_context_xml(doc_base) else { return context_xml(doc_base, reload) };
merge_context_xml(&app, doc_base, reload).unwrap_or_else(|e| {
tracing::warn!("ignoring the web app's META-INF/context.xml: {e}");
context_xml(doc_base, reload)
})
}
fn merge_context_xml(app: &str, doc_base: &Path, reload: bool) -> IdeResult<String> {
let mut reader = Reader::from_str(app);
let mut writer = Writer::new(Vec::new());
let mut root = false;
loop {
let event = reader.read_event().map_err(invalid)?;
let event = match event {
Event::Eof => break,
Event::Start(ref tag) | Event::Empty(ref tag) if !root => {
if tag.name().as_ref() != "Context" {
return Err(invalid("the root element is not <Context>"));
}
root = true;
let mut new = BytesStart::new("Context");
for a in tag.attributes() {
let a = a.map_err(invalid)?;
if !matches!(a.key.as_ref(), "docBase" | "reloadable" | "path") {
new.push_attribute(a);
}
}
new.push_attribute(("docBase", doc_base.to_string_lossy().as_ref()));
new.push_attribute(("reloadable", if reload { "true" } else { "false" }));
if matches!(event, Event::Empty(_)) { Event::Empty(new) } else { Event::Start(new) }
}
e => e,
};
writer.write_event(event).map_err(invalid)?;
}
if !root {
return Err(invalid("no <Context> element"));
}
String::from_utf8(writer.into_inner()).map_err(invalid)
}
pub fn patch_server_xml(xml: &str, http: u16, shutdown: u16, secret: &str) -> IdeResult<String> {
let mut reader = Reader::from_str(xml);
let mut writer = Writer::new(Vec::new());
@@ -260,7 +204,7 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
}
/// Updates the exploded artifact deployment from the module's own class output directories and
/// its web resources ([`web_sources`]), file by file and only what changed, instead of
/// its web resources (`src/main/webapp`), file by file and only what changed, instead of
/// rebuilding and re-exploding the WAR. `module` and `class_paths` are absolute.
/// `Ok(false)`: not possible this way (not deployed from the artifact yet, the module's output
/// isn't on the classpath, or a module it depends on was compiled since the last update, whose
@@ -288,10 +232,9 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
for dir in own {
sync_tree(dir, dir, &classes, Path::new("WEB-INF/classes"), resources_only, &mut current)?;
}
for (dir, target) in web_sources(module) {
if dir.is_dir() {
sync_tree(&dir, &dir, &dest.join(&target), &target, resources_only, &mut current)?;
}
let webapp = module.join("src/main/webapp");
if webapp.is_dir() {
sync_tree(&webapp, &webapp, &dest, Path::new(""), resources_only, &mut current)?;
}
for rel in previous.iter().filter(|p| !current.contains(*p)) {
let path = dest.join(rel);
@@ -303,59 +246,6 @@ pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resourc
Ok(true)
}
/// The folders whose files the WAR puts at its root (or under the second path): the web app
/// folder (`src/main/webapp`, maven-war-plugin's `warSourceDirectory`, Gradle's
/// `webAppDirName` / `webAppDirectory`), then maven-war-plugin's `webResources`.
pub fn web_sources(module: &Path) -> Vec<(PathBuf, PathBuf)> {
let rel = |dir: &str| {
let dir = ["${project.basedir}/", "${basedir}/"].iter().find_map(|p| dir.strip_prefix(p)).unwrap_or(dir);
module.join(dir.trim())
};
if let Ok(pom) = fs::read_to_string(module.join("pom.xml"))
&& let Some(war) = war_plugin(&pom)
{
let mut out = vec![(rel(tag(war, "warSourceDirectory").unwrap_or("src/main/webapp")), PathBuf::new())];
let mut rest = tag_block(war, "webResources").unwrap_or_default();
while let Some(start) = rest.find("<resource>") {
let end = rest[start..].find("</resource>").map_or(rest.len(), |e| start + e);
let resource = &rest[start..end];
if let Some(dir) = tag(resource, "directory") {
out.push((rel(dir), PathBuf::from(tag(resource, "targetPath").unwrap_or_default())));
}
rest = &rest[end..];
}
return out;
}
let gradle = ["build.gradle", "build.gradle.kts"].iter().find_map(|f| fs::read_to_string(module.join(f)).ok());
let web_app_dir = gradle.as_deref().and_then(|text| {
text.lines()
.filter(|l| !l.trim_start().starts_with("//"))
.find(|l| l.contains("webAppDirName") || l.contains("webAppDirectory"))
.and_then(|l| l.split(['"', '\'']).nth(1).map(str::to_owned))
});
vec![(rel(web_app_dir.as_deref().unwrap_or("src/main/webapp")), PathBuf::new())]
}
/// The `<plugin>` element of maven-war-plugin in `pom`, if it is configured there.
fn war_plugin(pom: &str) -> Option<&str> {
let at = pom.find("<artifactId>maven-war-plugin</artifactId>")?;
let start = pom[..at].rfind("<plugin>")?;
let end = pom[at..].find("</plugin>").map_or(pom.len(), |e| at + e);
Some(&pom[start..end])
}
/// The text of the first `<name>` element in `xml`.
fn tag<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
tag_block(xml, name).map(str::trim).filter(|t| !t.is_empty())
}
fn tag_block<'a>(xml: &'a str, name: &str) -> Option<&'a str> {
let open = format!("<{name}>");
let start = xml.find(&open)? + open.len();
let end = start + xml[start..].find(&format!("</{name}>"))?;
Some(&xml[start..end])
}
fn modified_since(dir: &Path, since: std::time::SystemTime) -> bool {
let Ok(entries) = fs::read_dir(dir) else { return false };
entries.flatten().any(|e| match e.file_type() {
@@ -498,7 +388,7 @@ pub fn prepare(root: &Path, cache: &Path, config: &mut RunConfig) -> IdeResult<(
let server = patch_server_xml(&fs::read_to_string(home.join("conf/server.xml"))?, port, shutdown, &secret)?;
fs::write(base.join("conf/server.xml"), server)?;
let debug = config.extras.params.get("tomcatDebug").is_some_and(|v| v == "true");
fs::write(contexts.join(format!("{context}.xml")), deployed_context_xml(&doc, !debug))?;
fs::write(contexts.join(format!("{context}.xml")), context_xml(&doc, !debug))?;
config.env.insert("CATALINA_HOME".into(), home.to_string_lossy().into_owned());
config.env.insert("CATALINA_BASE".into(), base.to_string_lossy().into_owned());
if let Some(jre) = config.extras.params.get("jre").filter(|s| !s.trim().is_empty()) {
@@ -557,48 +447,6 @@ mod tests {
}
zip.finish().unwrap();
}
#[test]
fn keeps_the_apps_own_context_descriptor() {
let tmp = tempfile::tempdir().unwrap();
let app = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context path=\"/old\" docBase=\"old\" reloadable=\"false\" antiResourceLocking=\"true\">\n <!-- JNDI -->\n <Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>\n <Environment name=\"mode\" value=\"dev\" type=\"java.lang.String\"/>\n</Context>\n";
let dir = tmp.path().join("web app");
fs::create_dir_all(dir.join("META-INF")).unwrap();
fs::write(dir.join("META-INF/context.xml"), app).unwrap();
let war = tmp.path().join("app.war");
archive(&war, &[("META-INF/context.xml", app), ("index.jsp", "")]);
for doc in [&dir, &war] {
let xml = deployed_context_xml(doc, true);
let doc_base = escape(&doc.to_string_lossy());
assert!(
xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<Context antiResourceLocking=\"true\""),
"{xml}"
);
assert!(xml.contains(&format!("docBase=\"{doc_base}\" reloadable=\"true\">")), "{xml}");
assert!(!xml.contains("/old") && !xml.contains("\"old\"") && !xml.contains("\"false\""), "{xml}");
assert!(xml.contains("<!-- JNDI -->"));
assert!(xml.contains("<Resource name=\"jdbc/x\" auth=\"Container\" type=\"javax.sql.DataSource\" url=\"jdbc:h2:mem:a;b=&amp;c\"/>"));
assert!(xml.contains("<Environment name=\"mode\""));
assert!(xml.trim_end().ends_with("</Context>"));
}
fs::write(dir.join("META-INF/context.xml"), "<Context/>").unwrap();
let doc_base = escape(&dir.to_string_lossy());
assert_eq!(
deployed_context_xml(&dir, false),
format!("<Context docBase=\"{doc_base}\" reloadable=\"false\"/>")
);
// Unreadable or not a context: the IDE's own descriptor, as before.
for broken in ["<Context><Resource></Context>", "<Server/>", ""] {
fs::write(dir.join("META-INF/context.xml"), broken).unwrap();
assert_eq!(deployed_context_xml(&dir, false), context_xml(&dir, false), "{broken}");
}
let plain = tmp.path().join("plain");
fs::create_dir_all(&plain).unwrap();
assert_eq!(deployed_context_xml(&plain, true), context_xml(&plain, true));
let bare = tmp.path().join("bare.war");
archive(&bare, &[("index.jsp", "")]);
assert_eq!(deployed_context_xml(&bare, true), context_xml(&bare, true));
}
fn config() -> RunConfig {
RunConfig {
id: "local:demo".into(),
@@ -726,83 +574,6 @@ mod tests {
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
}
#[test]
fn web_sources_follow_the_war_plugin_and_gradle_settings() {
let tmp = tempfile::tempdir().unwrap();
let module = tmp.path();
let rel = |sources: Vec<(PathBuf, PathBuf)>| -> Vec<(String, String)> {
sources
.into_iter()
.map(|(d, t)| {
let d = d.strip_prefix(module).unwrap().to_string_lossy().replace('\\', "/");
(d, t.to_string_lossy().into_owned())
})
.collect()
};
assert_eq!(rel(web_sources(module)), [("src/main/webapp".to_owned(), String::new())]);
// An Eclipse-style layout: the JSPs come in through webResources.
fs::write(
module.join("pom.xml"),
r#"<project><build><plugins>
<plugin><artifactId>maven-compiler-plugin</artifactId></plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-war-plugin</artifactId>
<configuration>
<warSourceDirectory>${basedir}/web</warSourceDirectory>
<webResources>
<resource><directory>WebContent</directory><includes><include>**/*</include></includes></resource>
<resource><directory>extra</directory><targetPath>static</targetPath></resource>
</webResources>
</configuration>
</plugin>
</plugins></build></project>"#,
)
.unwrap();
assert_eq!(
rel(web_sources(module)),
[
("web".to_owned(), String::new()),
("WebContent".to_owned(), String::new()),
("extra".to_owned(), "static".to_owned())
]
);
fs::remove_file(module.join("pom.xml")).unwrap();
fs::write(
module.join("build.gradle"),
"plugins { id 'war' }\n// webAppDirName = 'old'\nwebAppDirName = 'WebContent'\n",
)
.unwrap();
assert_eq!(rel(web_sources(module)), [("WebContent".to_owned(), String::new())]);
}
#[test]
fn updates_web_resources_outside_src_main_webapp() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let (base, module) = (root.join("base"), root.join("web"));
let (dest, out) = (base.join("deployment"), module.join("target/classes"));
for dir in [dest.join("WEB-INF/classes"), out.clone(), module.join("WebContent/jsp")] {
fs::create_dir_all(dir).unwrap();
}
fs::write(
module.join("pom.xml"),
"<project><plugin><artifactId>maven-war-plugin</artifactId><configuration><webResources>\
<resource><directory>WebContent</directory></resource></webResources></configuration></plugin></project>",
)
.unwrap();
fs::write(dest.join("index.jsp"), "old").unwrap();
fs::write(module.join("WebContent/jsp/list.jsp"), "new").unwrap();
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
assert!(sync_outputs(&base, &module, std::slice::from_ref(&out), true).unwrap());
assert_eq!(fs::read_to_string(dest.join("jsp/list.jsp")).unwrap(), "new");
// Files the WAR brought that no source here maps to stay.
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "old");
}
#[test]
fn deployment_errors_identify_the_checked_path() {
let tmp = tempfile::tempdir().unwrap();
-19
View File
@@ -820,25 +820,6 @@ task default: :build
assert_eq!(find(&c, "rake:build").extras.folder.as_deref(), Some("Rake"));
}
#[test]
fn ruby_minitest_projects_get_a_test_runner() {
let dir = project(&[("Gemfile", "source 'https://rubygems.org'\n"), ("lib/a.rb", ""), ("test/a_test.rb", "")]);
let c = detect(dir.path());
let m = find(&c, "minitest");
assert!(m.is_test);
assert_eq!(m.kind, "ruby");
assert_eq!(
(m.command.as_str(), &m.args[..4]),
("bundle", &["exec", "ruby", "-Ilib", "-Itest"].map(String::from)[..])
);
assert_eq!(m.args.last().map(String::as_str), Some("test"));
// A `test` Rake task or RSpec already runs the tests.
let dir = project(&[("Rakefile", "task :test\n"), ("test/a_test.rb", "")]);
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
let dir = project(&[("Gemfile", "gem 'rspec'\n"), ("test/a_test.rb", "")]);
assert!(!ids(&detect(dir.path())).contains(&"minitest"));
}
#[test]
fn ruby_without_bundler_runs_bare_tools() {
let dir = project(&[("Rakefile", "task :test\n")]);
+8 -93
View File
@@ -239,8 +239,8 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
<envs><env name="VITE_API" value="http://localhost:8000" /></envs>
<method v="2" />
</configuration>
<configuration name="Main" type="JUnit" factoryName="JUnit">
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
<configuration name="Main" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="shop.Main" />
<method v="2" />
</configuration>"#
)),
@@ -272,7 +272,7 @@ fn workspace_run_manager_skips_templates_and_temporary_configurations() {
assert_eq!(get(&r, "Custom").args, s(&["-m", "pytest", "tests/a.py", "--maxfail=2"]));
assert_eq!(reason(&r, "Unittests in tests"), &SkipReason::UnsupportedType { type_: "tests (Unittests)".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "Application".into() });
let dev = get(&r, "dev");
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
@@ -526,11 +526,14 @@ fn sources_are_offered_when_their_files_exist() {
),
);
assert!(available(dir.path()).is_empty());
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
write(dir.path(), ".vscode/launch.json", "{}");
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
assert_eq!(
reason(&import_from(dir.path(), &[]), "App"),
&SkipReason::UnsupportedType { type_: "Application".into() }
);
}
#[test]
@@ -699,91 +702,3 @@ fn flags_defaults_inherited_variables_and_nested_django_tests() {
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
}
#[test]
fn java_applications_and_spring_boot_keep_their_module() {
let dir = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(dir.path()).unwrap();
write(&root, "pom.xml", "<project><artifactId>shop</artifactId><packaging>pom</packaging></project>");
write(
&root,
"example-service/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>example-service</artifactId></project>",
);
// IDEA names a Maven module after its artifactId, which needn't be its folder.
write(
&root,
"services/api/pom.xml",
"<project><parent><artifactId>shop</artifactId></parent><artifactId>api-service</artifactId></project>",
);
write(
&root,
".idea/workspace.xml",
&workspace(
r#" <configuration name="ExampleApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.example.ExampleApplication" />
<option name="ACTIVE_PROFILES" value="dev,local" />
<option name="VM_PARAMETERS" value="-Xmx512m -Dfile.encoding=UTF-8" />
<option name="PROGRAM_PARAMETERS" value="--server.port=8081" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/example-service" />
<option name="HIDE_BANNER" value="true" />
<envs>
<env name="TZ" value="UTC" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
<configuration name="Api" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="com.example.api.Main" />
<module name="api-service" />
<option name="ALTERNATIVE_JRE_PATH_ENABLED" value="true" />
<option name="ALTERNATIVE_JRE_PATH" value="17" />
<method v="2" />
</configuration>
<configuration name="Gone" type="Application" factoryName="Application">
<option name="MAIN_CLASS_NAME" value="Gone" />
<module name="removed-module" />
</configuration>
<configuration name="NoMain" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot">
<module name="example-service" />
</configuration>"#,
),
);
let r = import_from(&root, &[]);
let boot = get(&r, "ExampleApplication");
assert_eq!(boot.kind, "spring-boot");
assert_eq!(boot.command, "java");
assert_eq!(
boot.args,
s(&[
"-Xmx512m",
"-Dfile.encoding=UTF-8",
"-cp",
"${classpath}",
"com.example.ExampleApplication",
"--spring.profiles.active=dev,local",
"--server.port=8081",
])
);
assert_eq!(boot.extras.module.as_deref(), Some("example-service"));
assert_eq!(cwd(boot), Some(PathBuf::from("example-service")));
assert_eq!(boot.env.get("TZ").map(String::as_str), Some("UTC"));
assert_eq!(boot.debug_adapter.as_deref(), Some("java-debug"));
assert_eq!(ignored(&r, "ExampleApplication"), ["before launch", "hide banner"]);
let api = get(&r, "Api");
assert_eq!(api.kind, "application");
assert_eq!(api.args, s(&["-cp", "${classpath}", "com.example.api.Main"]));
assert_eq!(api.extras.module.as_deref(), Some("services/api"));
assert_eq!(ignored(&r, "Api"), ["JRE"]);
// A module that isn't here any more: the form finds it by the main class.
assert_eq!(get(&r, "Gone").extras.module, None);
assert_eq!(ignored(&r, "Gone"), ["module removed-module"]);
assert_eq!(reason(&r, "NoMain"), &SkipReason::NothingToRun);
}
-30
View File
@@ -479,36 +479,6 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
}
}
#[test]
fn ruby_test_runs_load_the_reporter_and_other_runs_do_not() {
let reporter = std::env::temp_dir().join("ruby reporter");
let run = |kind: &str, is_test: bool| {
let h = harness();
let mut config =
cfg("ruby-env", "/bin/sh", &["-c", r#"echo "lib=$RUBYLIB opt=$RUBYOPT report=$OPENIDEA_TEST_REPORT""#]);
config.kind = kind.into();
config.is_test = is_test;
let options = StartOptions { ruby_reporter: Some(reporter.clone()), ..Default::default() };
let chunks: Chunks = Arc::default();
let sink = chunks.clone();
let info =
h.pm.start(h.root.path(), &config, &options, h.sink.clone(), Box::new(move |c| sink.lock().push(c)))
.unwrap();
assert_eq!(wait_exit(&h, info.id, Duration::from_secs(10)), Some(0));
text(&chunks, stdout)
};
for kind in ["ruby", "rake"] {
let out = run(kind, true);
// The directory has a space: it is on RUBYLIB and only the bare feature name is in RUBYOPT.
assert!(out.contains(&format!("lib={}", reporter.display())), "{out}");
assert!(out.contains("-ropenidea_test_hook") && !out.contains(&format!("-r{}", reporter.display())), "{out}");
assert!(out.contains("report=1"), "{out}");
}
for (kind, is_test) in [("ruby", false), ("rspec", true), ("pytest", true)] {
assert!(run(kind, is_test).contains("report=\n"), "{kind} {is_test}");
}
}
#[test]
fn revoking_one_project_stops_only_its_runs() {
let h = harness();
-84
View File
@@ -7,7 +7,6 @@ use crate::{angular, godot, pinned, vue};
use crate::{bin_dir, exe, venv_of};
use ide_core::settings::{InspectionLevel, InspectionSettings, LanguageServerSettings, Settings};
use ide_core::version_managers::Tool;
use ide_core::{IdeError, IdeResult};
use ide_language::{LanguageConfig, LanguageServerConfig};
use ide_run::RunConfig;
use serde_json::{Value, json};
@@ -29,39 +28,6 @@ pub fn setting_path(root: &Path, s: &str) -> Option<PathBuf> {
Some(if p.is_absolute() { p } else { root.join(p) })
}
fn toml_has_table(file: &Path, table: &str) -> bool {
let Ok(text) = std::fs::read_to_string(file) else { return false };
let Ok(value) = toml::from_str::<toml::Value>(&text) else { return false };
table.split('.').try_fold(&value, |value, key| value.get(key)).is_some_and(toml::Value::is_table)
}
/// The Ruff configuration file selected by `languages.ruff.configurationDirectory`.
/// A file can be chosen directly; a directory resolves `ruff.toml`, `.ruff.toml`,
/// then a `pyproject.toml` containing `[tool.ruff]`.
pub fn ruff_configuration(root: &Path, configured: &str) -> IdeResult<Option<PathBuf>> {
let Some(path) = setting_path(root, configured) else { return Ok(None) };
if path.is_file() {
return Ok(Some(path));
}
if !path.is_dir() {
return Err(IdeError::invalid(format!("Ruff configuration path does not exist: {}", path.display())));
}
for name in ["ruff.toml", ".ruff.toml"] {
let file = path.join(name);
if file.is_file() {
return Ok(Some(file));
}
}
let pyproject = path.join("pyproject.toml");
if toml_has_table(&pyproject, "tool.ruff") {
return Ok(Some(pyproject));
}
Err(IdeError::invalid(format!(
"Ruff configuration directory has no ruff.toml, .ruff.toml, or pyproject.toml with [tool.ruff]: {}",
path.display()
)))
}
/// The project interpreter: the configured one, else a project virtualenv
/// (`.venv`, `venv`, …) in the project root, else the Python the project pins for mise / asdf.
pub fn python_interpreter(root: &Path, s: &Settings) -> Option<PathBuf> {
@@ -609,18 +575,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
}
}
if name == "ruff" {
match ruff_configuration(root, &s.languages.ruff.configuration_directory) {
Ok(Some(path)) => {
let mut settings = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
settings["configuration"] = json!(path.to_string_lossy());
cfg.settings = Some(settings);
}
Ok(None) => {}
Err(e) => cfg.launch_error = Some(e.message),
}
}
if name == "rust-analyzer" {
let r = &s.languages.rust;
let extra_args: Vec<&str> = r.rustfmt_args.split_whitespace().collect();
@@ -737,13 +691,6 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
path_dirs.push(dir);
}
// Gems install to the Ruby's bindir, which is not always on `PATH` (Homebrew).
if name == "ruby-lsp"
&& let Some(ruby) = ruby_interpreter(root, s)
{
path_dirs.extend(crate::ruby::gem_bin_dirs(&ruby, server_root));
}
if !path_dirs.is_empty() {
let dirs: Vec<&Path> = path_dirs.iter().map(PathBuf::as_path).collect();
cfg.env.insert("PATH".into(), path_with(&dirs));
@@ -765,11 +712,6 @@ pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Se
out.push(custom_server(name, ls));
}
}
if non_empty(&s.languages.ruff.configuration_directory).is_some() {
for cfg in out.iter_mut().filter(|cfg| cfg.name == "ruff") {
cfg.require_root_marker = false;
}
}
out
}
@@ -1359,29 +1301,3 @@ mod maven_linkage_tests {
assert!(serde_json::to_value(cfg.initialization_options).unwrap().get("projectConfigurations").is_none());
}
}
#[cfg(all(test, unix))]
mod ruby_lsp_tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
#[test]
fn ruby_lsp_is_found_in_the_rubys_gem_bindir_when_that_is_off_path() {
let tmp = tempfile::tempdir().unwrap();
let bindir = tmp.path().join("gems/bin");
std::fs::create_dir_all(&bindir).unwrap();
// Homebrew's Ruby: `Gem.bindir` is not on `PATH`.
let ruby = tmp.path().join("ruby");
std::fs::write(&ruby, format!("#!/bin/sh\necho '{}'\necho '/nonexistent/user/bin'\n", bindir.display()))
.unwrap();
std::fs::set_permissions(&ruby, std::fs::Permissions::from_mode(0o755)).unwrap();
let mut s = Settings::default();
s.languages.ruby.interpreter = ruby.to_string_lossy().into_owned();
let mut cfg = custom_server("ruby-lsp", &LanguageServerSettings::default());
configure_server(&mut cfg, tmp.path(), tmp.path(), &s);
let path = cfg.env.get("PATH").expect("PATH set");
let dirs: Vec<PathBuf> = std::env::split_paths(path).collect();
assert!(dirs.contains(&bindir), "{dirs:?}");
assert!(!dirs.iter().any(|d| d.starts_with("/nonexistent")), "{dirs:?}");
}
}
+4 -48
View File
@@ -282,38 +282,8 @@ pub(crate) fn php_source(path: &Path, home: Option<&Path>) -> &'static str {
}
}
/// A PHP CLI named with its version, as Debian / Ubuntu (`ppa:ondrej/php`) install them:
/// `php8.1`, `php81`. Not `php-cgi8.1`, `phpize8.1` or `phpdbg8.1`.
pub(crate) fn is_versioned_php_name(name: &str) -> bool {
let name = name.strip_suffix(".exe").unwrap_or(name);
let Some(version) = name.strip_prefix("php") else { return false };
let mut parts = version.splitn(2, '.');
let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
parts.next().is_some_and(digits) && parts.next().is_none_or(digits)
}
/// The versioned PHP CLIs in `dirs`, in `PATH` order (sorted by name within a directory).
pub(crate) fn versioned_php_in(dirs: impl IntoIterator<Item = PathBuf>) -> Vec<PathBuf> {
let mut found = Vec::new();
for dir in dirs {
if version_managers::is_shims_dir(&dir) {
continue;
}
let mut names: Vec<String> = std::fs::read_dir(&dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| is_versioned_php_name(n))
.collect();
names.sort();
found.extend(names.into_iter().map(|n| dir.join(n)).filter(|p| p.is_file()));
}
found
}
/// `php` CLIs: every one on `PATH` (also versioned ones, `php8.1`), Homebrew's versioned
/// formulae, PHP version managers, Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
/// `php` CLIs: every one on `PATH`, Homebrew's versioned formulae, PHP version managers,
/// Laravel Herd, and the usual Windows distributions (XAMPP, Laragon).
fn php() -> Vec<Sdk> {
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
let home = home_dir();
@@ -324,11 +294,6 @@ fn php() -> Vec<Sdk> {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
// After the plain `php`: deduplication keeps `/usr/bin/php` over the `php8.3` it links to.
for p in versioned_php_in(std::env::split_paths(&search_path())) {
let source = php_source(&p, home.as_deref());
found.push((p, source));
}
let named = |dir: &Path, prefix: &str| -> Vec<PathBuf> {
subdirs(dir)
.into_iter()
@@ -367,7 +332,7 @@ fn php() -> Vec<Sdk> {
}
}
let found = dedupe_by_real_path(found);
let mut sdks = par_map(&found, |(path, source)| {
par_map(&found, |(path, source)| {
let version = query(path, &["-r", "echo PHP_VERSION;"], VERSION_TIMEOUT)?;
let version = version.trim().to_owned();
if version.is_empty() {
@@ -381,16 +346,7 @@ fn php() -> Vec<Sdk> {
home: path.parent().and_then(Path::parent).map(Into::into),
extra: None,
})
});
newest_first(&mut sdks);
sdks
}
/// Newest version first, number by number (8.3.12 before 8.3.9); stable, so on equal
/// versions the `PATH` install stays first.
pub(crate) fn newest_first(sdks: &mut [Sdk]) {
let key = |s: &Sdk| ide_core::tool_path::version_key(s.version.as_deref().unwrap_or(""));
sdks.sort_by_key(|s| std::cmp::Reverse(key(s)));
})
}
fn node() -> Vec<Sdk> {
+1 -1
View File
@@ -1169,7 +1169,7 @@ mod kotlin_bridge_bundle_tests {
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
let extension = data_dir.join("java-usages-1.5.0.jar");
let extension = data_dir.join("java-usages-1.3.0.jar");
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
clean_usages_bundles(data_dir, &extension);
+2 -173
View File
@@ -1,14 +1,12 @@
//! 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);
//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes;
//! and the PHPStan run that checks saved files.
//! the Intelephense storage that goes with it; and the stub sets and include paths Intelephense indexes.
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, PathBuf};
use std::process::Command;
use std::path::Path;
/// A PHP version and where it comes from.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
@@ -52,43 +50,6 @@ pub fn composer_level(root: &Path) -> Option<PhpLanguageLevel> {
.map(|v| PhpLanguageLevel::new(v, PhpLevelSource::Require))
}
/// Why `composer install` is due in a Composer project.
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub enum ComposerInstall {
/// No `vendor/autoload.php`.
Missing,
/// A package of `composer.lock` is not installed, or at another version.
Outdated,
}
/// The Composer project `file` belongs to (the nearest `composer.json` at or below `root`), and
/// whether its dependencies need `composer install`.
pub fn composer_install(file: &Path, root: &Path) -> Option<(PathBuf, ComposerInstall)> {
let dir = file.ancestors().filter(|d| d.starts_with(root)).find(|d| d.join("composer.json").is_file())?;
let composer: Value = serde_json::from_str(&std::fs::read_to_string(dir.join("composer.json")).ok()?).ok()?;
let vendor = dir.join(composer.pointer("/config/vendor-dir").and_then(Value::as_str).unwrap_or("vendor"));
if !vendor.join("autoload.php").is_file() {
return Some((dir.to_path_buf(), ComposerInstall::Missing));
}
let read = |path: PathBuf| std::fs::read_to_string(path).ok().and_then(|t| serde_json::from_str::<Value>(&t).ok());
let lock = read(dir.join("composer.lock"))?;
let installed = read(vendor.join("composer/installed.json")).unwrap_or(Value::Null);
// Composer 2 writes `{"packages": [...], "dev": bool}`, Composer 1 the list itself.
let packages = installed.get("packages").unwrap_or(&installed).as_array().cloned().unwrap_or_default();
let versions: std::collections::HashMap<&str, &str> =
packages.iter().filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?))).collect();
let dev = installed.get("dev").and_then(Value::as_bool).unwrap_or(true);
let sections: &[&str] = if dev { &["packages", "packages-dev"] } else { &["packages"] };
let outdated = sections
.iter()
.filter_map(|section| lock.get(*section).and_then(Value::as_array))
.flatten()
.filter_map(|p| Some((p.get("name")?.as_str()?, p.get("version")?.as_str()?)))
.any(|(name, version)| versions.get(name) != Some(&version));
outdated.then(|| (dir.to_path_buf(), ComposerInstall::Outdated))
}
/// Gives each PHP version its own Intelephense workspace storage (below `data_dir`).
/// Intelephense keeps the built-in functions of the version a workspace was first indexed
/// with in that storage, and reuses them after the version changes. Without a version, or
@@ -178,33 +139,6 @@ pub fn include_paths(root: &Path, s: &Settings) -> Vec<String> {
.collect()
}
/// `vendor/bin/phpstan analyse` of `files` with a JSON report and the configuration file of
/// Settings › PHP, run from `root` with Xdebug off: in debug mode it would try to reach a
/// debugger on every run. The selected PHP runs the script itself (and is first on `PATH`, as for
/// PHPUnit), so a `php8.1` is not replaced by the `php` its `#!/usr/bin/env php` finds, and
/// Windows needs no `phpstan.bat`, whose `cmd` limits the command line. `None` when the project
/// has no PHPStan.
pub fn phpstan_command(root: &Path, s: &Settings, files: &[PathBuf]) -> Option<Command> {
let bin = root.join("vendor/bin/phpstan");
if !bin.is_file() {
return None;
}
let php = setting_path(root, &s.languages.php.interpreter)
.or_else(|| crate::exec::which("php"))
.unwrap_or_else(|| PathBuf::from("php"));
let mut cmd = Command::new(php);
cmd.current_dir(root).arg(bin).args(["analyse", "--error-format=json", "--no-progress"]);
if let Some(config) = setting_path(root, &s.languages.php.phpstan_config) {
cmd.arg("--configuration").arg(config);
}
cmd.args(files);
cmd.env("XDEBUG_MODE", "off");
if let Some(dir) = crate::apply::php_dir(root, s) {
cmd.env("PATH", crate::exec::path_with(&[&dir]));
}
Some(cmd)
}
type Version = (u32, u32, u32);
impl PhpLanguageLevel {
@@ -263,39 +197,6 @@ fn lower_bound(alternative: &str) -> Option<Version> {
mod tests {
use super::*;
#[test]
fn composer_install_is_due_without_vendor_or_when_the_lock_differs() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let app = root.join("app");
std::fs::create_dir_all(app.join("src")).unwrap();
let file = app.join("src/Kernel.php");
assert_eq!(composer_install(&file, root), None, "not a Composer project");
std::fs::write(app.join("composer.json"), r#"{"config": {"vendor-dir": "lib"}}"#).unwrap();
assert_eq!(composer_install(&file, root), Some((app.clone(), ComposerInstall::Missing)));
std::fs::create_dir_all(app.join("lib/composer")).unwrap();
std::fs::write(app.join("lib/autoload.php"), "<?php").unwrap();
assert_eq!(composer_install(&file, root), None, "installed, no lock to compare with");
let lock = r#"{"packages": [{"name": "a/a", "version": "v1.2.0"}], "packages-dev": [{"name": "t/t", "version": "10.0.0"}]}"#;
std::fs::write(app.join("composer.lock"), lock).unwrap();
let installed = |packages: &str, dev: bool| {
let json = format!(r#"{{"packages": [{packages}], "dev": {dev}}}"#);
std::fs::write(app.join("lib/composer/installed.json"), json).unwrap();
composer_install(&file, root)
};
let a = r#"{"name": "a/a", "version": "v1.2.0"}"#;
let t = r#"{"name": "t/t", "version": "10.0.0"}"#;
assert_eq!(installed(&format!("{a},{t}"), true), None);
assert_eq!(installed(a, false), None, "`--no-dev` leaves out the dev packages");
assert_eq!(installed(a, true), Some((app.clone(), ComposerInstall::Outdated)), "a dev package is missing");
let old = r#"{"name": "a/a", "version": "v1.1.0"}"#;
assert_eq!(installed(&format!("{old},{t}"), true), Some((app.clone(), ComposerInstall::Outdated)));
// Composer 1 lists the packages at the top level.
std::fs::write(app.join("lib/composer/installed.json"), format!("[{a},{t}]")).unwrap();
assert_eq!(composer_install(&file, root), None);
assert_eq!(composer_install(&root.join("other.php"), root), None, "outside the Composer project");
}
fn level(composer: Option<&str>, explicit: &str) -> Option<String> {
levels(composer, explicit).0.map(|l| l.version)
}
@@ -449,78 +350,6 @@ mod tests {
);
}
fn phpstan(root: &Path, s: &Settings) -> Option<Command> {
let cmd = phpstan_command(root, s, &[root.join("src").join("A.php"), root.join("src").join("B.php")])?;
assert_eq!(cmd.get_current_dir(), Some(root));
Some(cmd)
}
fn text(s: &std::ffi::OsStr) -> String {
s.to_string_lossy().into_owned()
}
fn env(cmd: &Command) -> Vec<(String, Option<String>)> {
cmd.get_envs().map(|(k, v)| (text(k), v.map(text))).collect()
}
#[test]
fn phpstan_checks_the_files_on_the_selected_php_without_xdebug() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let mut s = Settings::default();
assert!(phpstan(root, &s).is_none(), "no vendor/bin/phpstan, no run");
let bin = root.join("vendor/bin/phpstan");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "").unwrap();
let cmd = phpstan(root, &s).unwrap();
// The PHP on PATH unless one is selected.
assert_eq!(Path::new(cmd.get_program()).file_stem(), Some("php".as_ref()));
let file = |name: &str| root.join("src").join(name).to_string_lossy().into_owned();
assert_eq!(
cmd.get_args().map(text).collect::<Vec<_>>(),
[
&*bin.to_string_lossy(),
"analyse",
"--error-format=json",
"--no-progress",
&file("A.php"),
&file("B.php")
]
);
assert_eq!(env(&cmd), [("XDEBUG_MODE".to_string(), Some("off".to_string()))]);
// A selected PHP runs PHPStan whatever its name, and is first on PATH for what PHPStan starts.
let php = root.join("php85/bin").join(crate::exe("php8.5"));
s.languages.php.interpreter = php.to_string_lossy().into_owned();
let cmd = phpstan(root, &s).unwrap();
assert_eq!(cmd.get_program(), php.as_os_str());
let env = env(&cmd);
let path = env.iter().find(|(k, _)| k == "PATH").and_then(|(_, v)| v.clone()).expect("PATH");
assert_eq!(std::env::split_paths(&path).next(), Some(root.join("php85/bin")));
assert!(env.contains(&("XDEBUG_MODE".to_string(), Some("off".to_string()))));
}
#[test]
fn phpstan_uses_the_configuration_file_of_the_settings() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let bin = root.join("vendor/bin/phpstan");
std::fs::create_dir_all(bin.parent().unwrap()).unwrap();
std::fs::write(&bin, "").unwrap();
let args = |config: &str| {
let mut s = Settings::default();
s.languages.php.phpstan_config = config.into();
phpstan(root, &s).unwrap().get_args().map(text).collect::<Vec<_>>()
};
let resolved = root.join("build/phpstan.ci.neon").to_string_lossy().into_owned();
assert_eq!(args(" build/phpstan.ci.neon ")[4..6], ["--configuration".to_string(), resolved]);
let absolute = if cfg!(windows) { "C:/etc/phpstan.neon" } else { "/etc/phpstan.neon" };
assert_eq!(args(absolute)[4..6], ["--configuration", absolute]);
// Empty: PHPStan finds phpstan.neon, phpstan.neon.dist or phpstan.dist.neon itself.
assert!(!args(" ").iter().any(|a| a.starts_with("--configuration")));
}
#[test]
fn each_level_names_its_source() {
let found = |version: &str, source| Some(PhpLanguageLevel { version: version.into(), source });
-8
View File
@@ -140,14 +140,6 @@ pub fn bin_dir_of(ruby: &Path) -> Option<PathBuf> {
ruby.parent().map(Path::to_path_buf)
}
/// Where `ruby` installs gem executables (`Gem.bindir`, the `--user-install` one) that exist, asked
/// of the Ruby that version managers select in `root`: Homebrew's keeps its bindir off `PATH`.
pub fn gem_bin_dirs(ruby: &Path, root: &Path) -> Vec<PathBuf> {
query_in(ruby, &["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT], Some(root), &[], QUERY_TIMEOUT)
.map(|out| ide_core::tool_path::gem_bin_dirs(&out))
.unwrap_or_default()
}
type NamedRoots = Vec<(String, PathBuf)>;
/// Standard-library / site directories (`ruby/3.3.0`) and the gems Bundler resolves
+1 -77
View File
@@ -1,6 +1,6 @@
use crate::apply::{
apply_to_run_configs, configure_server, jdtls_jdk, maven_command, maven_options, project_jdk, python_interpreter,
ruff_configuration, terminal_activation, tsdk, without_derived,
terminal_activation, tsdk, without_derived,
};
use crate::python::parse_pip_list;
use crate::{bin_dir, exe, venv_of};
@@ -481,9 +481,6 @@ fn language_server_settings_disable_add_and_override() {
(ruff.command.as_str(), ruff.args.as_slice()),
("/opt/ruff", &["server".to_string(), "--preview".to_string()][..])
);
s.languages.ruff.configuration_directory = "ruff-config".into();
let defined = [server("ruff")];
assert!(!language_servers("python", &defined, &s)[0].require_root_marker);
s.language_servers.insert(
"rust-analyzer".into(),
@@ -526,38 +523,6 @@ fn language_server_settings_disable_add_and_override() {
assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()]));
}
#[test]
fn ruff_configuration_directory_resolves_files_and_validates_pyproject() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let dir = root.join("shared ruff");
std::fs::create_dir_all(&dir).unwrap();
assert_eq!(ruff_configuration(root, "").unwrap(), None);
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
let ruff = dir.join("ruff.toml");
std::fs::write(&ruff, "line-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(ruff.clone()));
assert_eq!(ruff_configuration(root, &ruff.to_string_lossy()).unwrap(), Some(ruff));
std::fs::remove_file(dir.join("ruff.toml")).unwrap();
let pyproject = dir.join("pyproject.toml");
std::fs::write(&pyproject, "[project]\nname = \"x\"\n").unwrap();
assert!(ruff_configuration(root, &dir.to_string_lossy()).unwrap_err().message.contains("has no"));
std::fs::write(&pyproject, "[tool.ruff]\nline-length = 100\n").unwrap();
assert_eq!(ruff_configuration(root, &dir.to_string_lossy()).unwrap(), Some(pyproject.clone()));
let mut settings = Settings::default();
settings.languages.ruff.configuration_directory = dir.to_string_lossy().into_owned();
let mut cfg = server("ruff");
configure_server(&mut cfg, root, root, &settings);
let configured = dir.join("pyproject.toml").to_string_lossy().into_owned();
assert_eq!(
cfg.settings.as_ref().and_then(|s| s.get("configuration")).and_then(|v| v.as_str()),
Some(configured.as_str())
);
}
#[test]
fn jdtls_jdk_setting() {
if cfg!(windows) {
@@ -1322,50 +1287,9 @@ fn php_sources_and_selected_interpreter() {
assert_eq!(source("/opt/homebrew/opt/[email protected]/bin/php"), "homebrew");
assert_eq!(source("/home/me/bin/php"), "path");
use crate::discover::{is_versioned_php_name, newest_first, versioned_php_in};
let sdk = |path: &str, version: &str| crate::Sdk {
path: std::path::PathBuf::from(path).into(),
name: format!("PHP {version}"),
version: Some(version.into()),
source: "path".into(),
home: None,
extra: None,
};
let mut sdks = vec![
sdk("/opt/homebrew/bin/php", "8.4.1"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.1.31"),
sdk("/opt/homebrew/opt/[email protected]/bin/php", "8.2.20"),
sdk("/home/me/.phpbrew/php/php-8.3.9/bin/php", "8.3.9"),
sdk("/home/me/.phpbrew/php/php-8.3.12/bin/php", "8.3.12"),
sdk("/home/me/.phpbrew/php/php-7.4.33/bin/php", "7.4.33"),
sdk("/usr/bin/php", "8.4.1"),
];
newest_first(&mut sdks);
let order: Vec<_> = sdks.iter().map(|s| s.version.clone().unwrap()).collect();
assert_eq!(order, ["8.4.1", "8.4.1", "8.3.12", "8.3.9", "8.2.20", "8.1.31", "7.4.33"]);
// Stable: on equal versions the PATH install stays first.
assert_eq!(sdks[0].path.0, std::path::PathBuf::from("/opt/homebrew/bin/php"));
for name in ["php8.1", "php81", "php8", "php8.3.exe"] {
assert!(is_versioned_php_name(name), "{name}");
}
for name in ["php", "php-cgi8.1", "phpize8.1", "phpdbg8.1", "php-fpm8.1", "php8.", "php.8", "php8.1.2x", "phpunit"]
{
assert!(!is_versioned_php_name(name), "{name}");
}
if cfg!(windows) {
return; // Unix paths
}
let bins = tempfile::tempdir().unwrap();
for name in ["php", "php8.3", "php8.1", "php-cgi8.1", "phpize8.1", "phpdbg8.1"] {
std::fs::write(bins.path().join(name), "").unwrap();
}
std::fs::create_dir(bins.path().join("php9.0")).unwrap();
assert_eq!(
versioned_php_in([bins.path().to_path_buf()]),
vec![bins.path().join("php8.1"), bins.path().join("php8.3")]
);
let tmp = tempfile::tempdir().unwrap();
let mut s = Settings::default();
assert_eq!(crate::apply::php_dir(tmp.path(), &s), None);
-63
View File
@@ -120,7 +120,6 @@ impl TerminalManager {
for (k, v) in env {
cmd.env(k, v);
}
apply_powershell_env(&shell, &mut cmd);
let child = pair
.slave
@@ -634,18 +633,6 @@ fn terminal_env(user: &BTreeMap<String, String>) -> BTreeMap<String, String> {
env
}
/// Older pwsh versions can crash while formatting a localized update banner
/// (PowerShell/PowerShell#28131). Keep explicit preferences in the final environment.
fn apply_powershell_env(shell: &str, cmd: &mut CommandBuilder) {
let name = shell.rsplit(['/', '\\']).next().unwrap_or(shell);
if cfg!(windows)
&& (name.eq_ignore_ascii_case("pwsh") || name.eq_ignore_ascii_case("pwsh.exe"))
&& cmd.get_env("POWERSHELL_UPDATECHECK").is_none_or(|v| v.is_empty())
{
cmd.env("POWERSHELL_UPDATECHECK", "Off");
}
}
/// Git Bash's MSYSTEM profile on Windows, when nobody chose one. A bare
/// `bash.exe` would run with the `MSYS` default, whose PATH leaves `git` and
/// the mingw64 tools out unless `<Git>\cmd` happens to be inherited — the Git
@@ -951,56 +938,6 @@ mod tests {
mod shell_start_on_every_platform {
use super::*;
#[test]
fn powershell_update_notifications_default_to_off_on_windows() {
for shell in ["pwsh", "pwsh.exe", "PWSH.EXE", "C:\\Program Files\\PowerShell\\7\\PwSh.ExE", "C:/pwsh/pwsh.exe"]
{
for value in [None, Some("")] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
if let Some(value) = value {
cmd.env("POWERSHELL_UPDATECHECK", value);
}
apply_powershell_env(shell, &mut cmd);
let want = if cfg!(windows) { Some("Off") } else { value };
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), want.map(std::ffi::OsStr::new), "{shell}");
}
}
}
#[test]
fn powershell_update_notification_preferences_are_preserved() {
for value in ["Default", "LTS", "Off"] {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", value);
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new(value)));
}
}
#[cfg(windows)]
#[test]
fn powershell_update_notification_preferences_ignore_key_case() {
let mut cmd = CommandBuilder::new("pwsh.exe");
cmd.env_clear();
cmd.env("POWERSHELL_UPDATECHECK", "Off");
cmd.env("powershell_updatecheck", "Default");
apply_powershell_env("pwsh.exe", &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), Some(std::ffi::OsStr::new("Default")));
assert_eq!(cmd.iter_full_env_as_str().count(), 1);
}
#[test]
fn other_shells_keep_their_update_notification_environment() {
for shell in ["powershell.exe", "cmd.exe", "wsl.exe", "bash.exe", "C:\\pwsh.exe\\other.exe", "other-pwsh.exe"] {
let mut cmd = CommandBuilder::new(shell);
cmd.env_clear();
apply_powershell_env(shell, &mut cmd);
assert_eq!(cmd.get_env("POWERSHELL_UPDATECHECK"), None, "{shell}");
}
}
#[test]
fn posix_shells_start_as_login_shells_everywhere() {
let (shell, args) = resolve_shell(Some("C:\\Program Files\\Git\\bin\\bash.exe"), &[]);
+12 -139
View File
@@ -8,18 +8,6 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::Duration;
/// A selection to format. Offsets are UTF-16 code units for Prettier; the
/// one-based line and column values are Unicode code points for Ruff.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct FormatRange {
pub start_offset: u32,
pub end_offset: u32,
pub start_line: u32,
pub start_col: u32,
pub end_line: u32,
pub end_col: u32,
}
/// Walk from the file's directory upward, including workspace parents (monorepos).
pub fn find_up(file: &Path, relative: &Path) -> Option<PathBuf> {
file.parent()?.ancestors().map(|dir| dir.join(relative)).find(|p| p.is_file())
@@ -29,24 +17,6 @@ pub fn display_name(tool: &str) -> &str {
crate::registry::find(tool).map_or(tool, |t| t.display)
}
/// A project's PHP-CS-Fixer: its committed configuration and `vendor/bin/php-cs-fixer`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PhpCsFixer {
pub config: PathBuf,
pub bin: PathBuf,
}
/// PHP-CS-Fixer for `file`: the nearest folder above it with `.php-cs-fixer.php` (which wins, as
/// PHP-CS-Fixer documents) or `.php-cs-fixer.dist.php`, and the nearest `vendor/bin/php-cs-fixer`.
/// `None` without both: Reformat Code keeps the language server.
pub fn php_cs_fixer(file: &Path) -> Option<PhpCsFixer> {
let config = file.parent()?.ancestors().find_map(|dir| {
[".php-cs-fixer.php", ".php-cs-fixer.dist.php"].iter().map(|name| dir.join(name)).find(|p| p.is_file())
})?;
let bin = find_up(file, &Path::new("vendor").join("bin").join("php-cs-fixer"))?;
Some(PhpCsFixer { config, bin })
}
fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
let name = display_name(tool);
let json: serde_json::Value = serde_json::from_slice(&std::fs::read(package.join("package.json"))?)
@@ -62,13 +32,9 @@ fn package_bin(package: &Path, tool: &str) -> IdeResult<PathBuf> {
Ok(path)
}
/// Prettier and Ruff format ranges for some languages (`config.range`); oxfmt formats whole files only.
/// Prettier formats a range for some languages (`config.range`); oxfmt formats whole files only.
pub fn supports_range(tool: &str, config: &FormatterConfig) -> bool {
matches!(tool, "prettier" | "ruff") && config.range
}
fn is_formatter(tool: &str) -> bool {
crate::registry::find(tool).is_some_and(|t| t.kind == crate::ToolKind::Formatter || t.name == "ruff")
tool == "prettier" && config.range
}
impl Tools {
@@ -78,7 +44,7 @@ impl Tools {
}
fn resolve_formatter_command(&self, tool: &str, file: &Path, custom: Option<&Path>) -> IdeResult<Command> {
if !is_formatter(tool) {
if crate::registry::find(tool).is_none_or(|t| t.kind != crate::ToolKind::Formatter) {
return Err(IdeError::unavailable(format!("Unknown external formatter: {tool}")));
}
let package = Path::new("node_modules").join(tool);
@@ -107,36 +73,18 @@ impl Tools {
}
}
/// Every tool resolves its configuration itself. Prettier's project configuration
/// overrides the editor indentation passed here; oxfmt and Ruff read their own
/// configuration files. Ruff also discovers the nearest `pyproject.toml`,
/// `ruff.toml`, or `.ruff.toml` from the stdin filename.
/// Both tools resolve their configuration and plugins themselves. Prettier's
/// project configuration overrides the editor indentation passed here; oxfmt has
/// no such flags and reads its configuration and `.editorconfig` only.
pub fn arguments(
tool: &str,
config: &FormatterConfig,
file: &Path,
tab_size: u32,
insert_spaces: bool,
range: Option<FormatRange>,
config_file: Option<&Path>,
range: Option<(u32, u32)>,
) -> Vec<OsString> {
let mut args: Vec<OsString> = Vec::new();
if tool == "ruff" {
args.push("format".into());
if let Some(config_file) = config_file {
args.extend(["--config".into(), config_file.as_os_str().to_owned()]);
}
args.extend(["--stdin-filename".into(), file.as_os_str().to_owned(), "-".into()]);
if supports_range(tool, config)
&& let Some(range) = range
{
args.extend([
"--range".into(),
format!("{}:{}-{}:{}", range.start_line, range.start_col, range.end_line, range.end_col).into(),
]);
}
return args;
}
if tool == "prettier" {
let parser = if config.parser == "markdown" && file.extension().is_some_and(|s| s == "mdx") {
"mdx"
@@ -159,14 +107,9 @@ pub fn arguments(
}
args.extend(["--stdin-filepath".into(), file.as_os_str().to_owned()]);
if supports_range(tool, config)
&& let Some(range) = range
&& let Some((start, end)) = range
{
args.extend([
"--range-start".into(),
range.start_offset.to_string().into(),
"--range-end".into(),
range.end_offset.to_string().into(),
]);
args.extend(["--range-start".into(), start.to_string().into(), "--range-end".into(), end.to_string().into()]);
}
// CLI defaults only find ignores in cwd; explicitly pass the nearest ancestor's files.
for name in [".prettierignore", ".gitignore"] {
@@ -177,33 +120,6 @@ pub fn arguments(
args
}
impl Tools {
/// `php vendor/bin/php-cs-fixer fix` of `target` with the project's configuration, from the
/// configuration's folder; `php` is the interpreter chosen in the settings, else the one on `PATH`.
pub fn php_cs_fixer_command(&self, php: Option<&Path>, fixer: &PhpCsFixer, target: &Path) -> IdeResult<Command> {
let php = match php {
Some(php) => php.to_path_buf(),
None => self.which("php").ok_or_else(|| IdeError::unavailable("PHP-CS-Fixer: PHP is not installed"))?,
};
let mut cmd = self.command(php);
cmd.arg(&fixer.bin)
.args(["fix", "--config"])
.arg(&fixer.config)
.args(["--using-cache=no", "--no-interaction", "--show-progress=none"])
.arg(target);
if let Some(dir) = fixer.config.parent() {
cmd.current_dir(dir);
}
Ok(cmd)
}
}
/// Runs a [`Tools::php_cs_fixer_command`], which rewrites its target file in place.
pub fn run_php_cs_fixer(mut command: Command, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, "", Duration::from_secs(30), cancel)
.map_err(|e| IdeError::invalid(format!("PHP-CS-Fixer: {}", e.message.trim())))
}
pub fn run(tool: &str, mut command: Command, text: &str, cancel: &CancellationToken) -> IdeResult<String> {
crate::process::run_input(&mut command, text, Duration::from_secs(15), cancel).map_err(|e| e.with_tool(tool))
}
@@ -265,15 +181,8 @@ mod tests {
("vue", true),
] {
let config = FormatterConfig { tool: "prettier".into(), parser: parser.into(), range: supports_range };
let range = Some(FormatRange {
start_offset: 3,
end_offset: 9,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 7,
});
let args = arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, range, None);
let args =
arguments("prettier", &config, Path::new("/project with spaces/file.md"), 4, false, Some((3, 9)));
let args: Vec<_> = args.iter().map(|a| a.to_string_lossy()).collect();
assert!(args.windows(2).any(|a| a == ["--config-precedence", "file-override"]));
assert!(args.windows(2).any(|a| a == ["--stdin-filepath", "/project with spaces/file.md"]));
@@ -308,9 +217,7 @@ mod tests {
);
let config = FormatterConfig { tool: "prettier".into(), parser: "typescript".into(), range: true };
let range =
Some(FormatRange { start_offset: 3, end_offset: 9, start_line: 1, start_col: 1, end_line: 1, end_col: 7 });
let args = arguments("oxfmt", &config, &file, 4, false, range, None);
let args = arguments("oxfmt", &config, &file, 4, false, Some((3, 9)));
let ignore = root.join(".prettierignore").into_os_string();
assert_eq!(
args[..4],
@@ -322,38 +229,4 @@ mod tests {
let unknown = tools.formatter_command("rust-analyzer", &file, None).unwrap_err();
assert!(unknown.message.contains("Unknown external formatter"), "{}", unknown.message);
}
#[test]
fn ruff_formats_stdin_and_uses_its_config_and_range_flags() {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let file = Path::new("/project with spaces/src/example.py");
let range = Some(FormatRange {
start_offset: 10,
end_offset: 20,
start_line: 2,
start_col: 5,
end_line: 4,
end_col: 1,
});
let config_file = Path::new("/shared/config/ruff.toml");
let args: Vec<_> = arguments("ruff", &config, file, 4, true, range, Some(config_file))
.into_iter()
.map(|arg| arg.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
[
"format",
"--config",
"/shared/config/ruff.toml",
"--stdin-filename",
"/project with spaces/src/example.py",
"-",
"--range",
"2:5-4:1",
]
);
assert!(supports_range("ruff", &config));
assert!(!args.iter().any(|arg| arg == "--stdin-filepath"));
}
}
+2 -91
View File
@@ -154,11 +154,7 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
Method::PipVenv { .. } => {
Some(root.join(spec.name).join("venv").join(if platform.os == Os::Windows { "Scripts" } else { "bin" }))
}
Method::Bundled { .. }
| Method::Rustup { .. }
| Method::PipUser { .. }
| Method::PipTarget { .. }
| Method::GemActive { .. } => None,
Method::Bundled { .. } | Method::Rustup { .. } | Method::PipUser { .. } | Method::PipTarget { .. } => None,
}
}
@@ -433,9 +429,6 @@ pub struct Tools {
/// Executables found outside the search path (app bundles), by command.
found: BTreeMap<String, PathBuf>,
version_timeout: Duration,
/// The project the tools are used for: tools that install into "the active runtime" (gems) run here,
/// so a version manager picks the project's Ruby.
work_dir: Option<PathBuf>,
}
impl Tools {
@@ -457,7 +450,6 @@ impl Tools {
custom: BTreeMap::new(),
found: BTreeMap::new(),
version_timeout: Duration::from_secs(4),
work_dir: None,
}
}
@@ -473,25 +465,10 @@ impl Tools {
self
}
/// Runs per-project installs (see [`registry::Method::GemActive`]) in `dir`.
pub fn with_work_dir(mut self, dir: Option<PathBuf>) -> Self {
self.work_dir = dir.filter(|d| d.is_dir());
self
}
pub fn root(&self) -> &Path {
&self.root
}
/// [`Tools::command`] in the project directory.
fn command_in_project(&self, program: impl AsRef<std::ffi::OsStr>) -> Command {
let mut cmd = self.command(program);
if let Some(dir) = &self.work_dir {
cmd.current_dir(dir);
}
cmd
}
fn which(&self, cmd: &str) -> Option<PathBuf> {
ide_core::tool_path::find_command(cmd, &self.system_path, &self.root)
}
@@ -637,9 +614,7 @@ impl Tools {
"npm or pnpm is required: install Node.js (https://nodejs.org)",
),
Method::Go { .. } => need(self.which("go").is_some(), "Go is required (https://go.dev/dl)"),
Method::Gem { .. } | Method::GemActive { .. } => {
need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)")
}
Method::Gem { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
Method::DotnetTool { .. } => {
need(self.which("dotnet").is_some(), ".NET SDK is required (https://dotnet.microsoft.com/download)")
}
@@ -797,22 +772,6 @@ impl Tools {
_ => (ToolStatus::Missing, None, None),
}
}
Method::GemActive { gem } => {
// Installed for the Ruby active in the project, not merely a shim of some other version's.
let Some(gem_cmd) = self.which("gem") else { return (ToolStatus::Missing, None, None) };
let mut c = self.command_in_project(&gem_cmd);
c.args(["list", "--exact", gem]);
match run_quiet(c, Duration::from_secs(10)) {
Some((true, out)) if out.contains(&format!("{gem} (")) => {
// Homebrew's Ruby keeps its gem bindir off PATH: ask the Ruby where it is.
match self.gem_executable(main) {
Some(path) => (ToolStatus::OnPath, Some(path), parse_version(&out)),
None => (ToolStatus::Missing, None, None),
}
}
_ => (ToolStatus::Missing, None, None),
}
}
_ => match self.which(main) {
Some(path) => {
let version = self.query_version(spec, &path);
@@ -909,7 +868,6 @@ impl Tools {
Method::Npm { packages } => self.install_npm(packages, cancel, emit)?,
Method::Go { module } => self.install_go(spec, module, cancel, emit)?,
Method::Gem { gem } => self.install_gem(spec, gem, cancel, emit)?,
Method::GemActive { gem } => self.install_gem_active(gem, cancel, emit)?,
Method::DotnetTool { package } => self.install_dotnet_tool(spec, package, cancel, emit)?,
Method::PipVenv { package } => self.install_venv(spec, package, cancel, emit)?,
Method::PipUser { package, .. } => self.install_pip_user(package, cancel, emit)?,
@@ -995,41 +953,6 @@ impl Tools {
self.write_manifest(spec.name, &Manifest { version, source: Some(module.to_string()) })
}
/// Where the project's Ruby installs gem executables (`Gem.bindir`, `Gem.user_dir`'s `bin`).
fn gem_bin_dirs(&self) -> Vec<PathBuf> {
let Some(ruby) = self.which("ruby") else { return Vec::new() };
let mut c = self.command_in_project(ruby);
c.args(["-e", ide_core::tool_path::RUBY_GEM_BIN_SCRIPT]);
match run_quiet(c, Duration::from_secs(10)) {
Some((true, out)) => ide_core::tool_path::gem_bin_dirs(&out),
_ => Vec::new(),
}
}
/// An installed gem's executable: on the search path, else in one of the Ruby's gem bin directories.
fn gem_executable(&self, cmd: &str) -> Option<PathBuf> {
self.which(cmd).or_else(|| self.gem_bin_dirs().iter().find_map(|d| self.exe_in(d, cmd)))
}
/// `gem install` into the active Ruby, then makes version-manager shims see the new executable.
fn install_gem_active(&self, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let mut c = self.command_in_project(self.which("gem").expect("checked"));
c.args(["install", "--no-document", gem]);
run_streamed(c, cancel, emit)?;
// mise / asdf / rbenv list executables in shims written at install time; a gem installed
// afterwards has none until they are regenerated. Best effort: the tool may not be installed.
for (manager, args) in
[("mise", &["reshim"][..]), ("asdf", &["reshim", "ruby"][..]), ("rbenv", &["rehash"][..])]
{
if let Some(path) = self.which(manager) {
let mut c = self.command_in_project(path);
c.args(args);
let _ = run_quiet(c, Duration::from_secs(30));
}
}
Ok(())
}
fn install_gem(&self, spec: &ToolSpec, gem: &str, cancel: &CancellationToken, emit: Emit) -> IdeResult<()> {
let dir = self.root.join(spec.name);
let _ = std::fs::remove_dir_all(&dir);
@@ -1387,18 +1310,6 @@ impl Tools {
run_streamed(c, &cancel, &quiet)?;
}
}
Method::GemActive { gem } => {
if let Some(gem_cmd) = self.which("gem") {
let mut c = self.command_in_project(&gem_cmd);
c.args(["uninstall", "--all", "--executables", "--ignore-dependencies", gem]);
run_streamed(c, &cancel, &quiet)?;
}
// A copy an older version installed under the tools directory.
let dir = self.root.join(spec.name);
if dir.exists() {
std::fs::remove_dir_all(&dir)?;
}
}
Method::Go { .. }
| Method::Gem { .. }
| Method::DotnetTool { .. }
+1 -7
View File
@@ -49,11 +49,6 @@ pub enum Method {
PipTarget { package: &'static str, module: &'static str },
/// `gem install` into `<tools>/<name>/gems`, launched through `GEM_HOME` wrappers in `bin/`.
Gem { gem: &'static str },
/// `gem install <gem>` into the active Ruby (the project's, installed from a project window),
/// launched through that Ruby's own executables. For tools that re-launch themselves under
/// Bundler (ruby-lsp), which breaks in a gem home under a path with spaces
/// (`~/Library/Application Support`: Bundler `exec`s a lone path string, and Ruby splits it).
GemActive { gem: &'static str },
/// `dotnet tool install --tool-path <tools>/<name>/tool <package>`, launched
/// through `DOTNET_ROOT` wrappers in `bin/`.
DotnetTool { package: &'static str },
@@ -69,7 +64,6 @@ impl Method {
Method::Npm { .. } => "npm",
Method::Go { .. } => "go install",
Method::Gem { .. } => "gem",
Method::GemActive { .. } => "gem (project Ruby)",
Method::DotnetTool { .. } => "dotnet tool",
Method::PipVenv { .. } => "pip (virtualenv)",
Method::PipUser { .. } => "pip --user",
@@ -946,7 +940,7 @@ pub static TOOLS: &[ToolSpec] = &[
display: "Ruby LSP (Shopify)",
kind: LanguageServer,
commands: &["ruby-lsp"],
method: Method::GemActive { gem: "ruby-lsp" },
method: Method::Gem { gem: "ruby-lsp" },
fallback: None,
version: V,
homepage: "https://shopify.github.io/ruby-lsp",
-45
View File
@@ -590,51 +590,6 @@ printf '#!/bin/sh\necho "$GEM_HOME"\n' > "$bin/solargraph"; /bin/chmod +x "$bin/
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), env.root.join("solargraph/gems").to_string_lossy());
}
#[cfg(unix)]
#[test]
fn ruby_lsp_installs_into_the_project_ruby_not_a_private_gem_home() {
let env = Env::new();
let project = env._tmp.path().join("project");
fs::create_dir_all(&project).unwrap();
let marker = env._tmp.path().join("installed");
// Homebrew's Ruby: the gem bindir is not on the search path; only the Ruby knows it.
let bindir = env._tmp.path().join("gems/bin");
fs::create_dir_all(&bindir).unwrap();
env.script(&env.sys, "ruby", &format!("echo '{}'", bindir.display()));
// `gem list --exact ruby-lsp` knows the gem once `gem install ruby-lsp` ran, in the cwd it was given.
env.script(
&env.sys,
"gem",
&format!(
r#"case "$1" in
install) echo "$PWD $@" > '{m}'; printf '#!/bin/sh\n' > '{b}/ruby-lsp'; /bin/chmod +x '{b}/ruby-lsp' ;;
list) [ -f '{m}' ] && echo "ruby-lsp (0.26.11)" ;;
uninstall) /bin/rm -f '{m}' ;;
esac"#,
m = marker.display(),
b = bindir.display()
),
);
let tools = env.tools().with_work_dir(Some(project.clone()));
assert_eq!(find(&tools.list(&langs()), "ruby-lsp").status, ToolStatus::Missing);
let info = tools.install("ruby-lsp", &langs(), &CancellationToken::new(), &|_| {}).unwrap();
assert_eq!(info.status, ToolStatus::OnPath);
assert_eq!(info.version.as_deref(), Some("0.26.11"));
// Found in the Ruby's own bindir, not reported as the `gem` command.
assert_eq!(info.path.as_deref(), Some(bindir.join("ruby-lsp").as_path()));
// Run in the project (so a version manager picks its Ruby), into the Ruby's own gem home.
let log = fs::read_to_string(&marker).unwrap();
assert!(log.starts_with(&*project.canonicalize().unwrap().to_string_lossy()), "{log}");
assert!(log.contains("install --no-document ruby-lsp") && !log.contains("--install-dir"), "{log}");
// No private gem home, no wrapper directory on the search path.
assert!(!env.root.join("ruby-lsp").exists());
// Uninstalling removes the gem, and an older managed copy.
fs::create_dir_all(env.root.join("ruby-lsp/gems")).unwrap();
let info = tools.uninstall("ruby-lsp", &langs()).unwrap();
assert_eq!(info.status, ToolStatus::Missing);
assert!(!env.root.join("ruby-lsp").exists());
}
#[cfg(unix)]
#[test]
fn dotnet_tool_install_into_tool_path() {
+1 -9
View File
@@ -19,15 +19,7 @@ fn format(
};
let mut cmd = tools.formatter_command("prettier", path, None)?;
cmd.current_dir(path.parent().unwrap());
let range = range.map(|(start_offset, end_offset)| formatter::FormatRange {
start_offset,
end_offset,
start_line: 1,
start_col: 1,
end_line: 1,
end_col: 1,
});
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range, None));
cmd.args(formatter::arguments("prettier", &config, path, 4, true, range));
formatter::run("prettier", cmd, text, &CancellationToken::new())
}
-67
View File
@@ -1,67 +0,0 @@
//! `cargo test -p ide-tools --test ruff -- --ignored --nocapture`
use ide_core::CancellationToken;
use ide_language::FormatterConfig;
use ide_tools::{Tools, formatter};
use std::path::Path;
fn format(
tools: &Tools,
path: &Path,
text: &str,
range: Option<formatter::FormatRange>,
config_file: Option<&Path>,
) -> ide_core::IdeResult<String> {
let config = FormatterConfig { tool: "ruff".into(), parser: "python".into(), range: true };
let mut cmd = tools.formatter_command("ruff", path, None)?;
cmd.current_dir(path.parent().unwrap());
cmd.args(formatter::arguments("ruff", &config, path, 4, true, range, config_file));
formatter::run("ruff", cmd, text, &CancellationToken::new())
}
#[test]
#[ignore = "requires ruff on PATH"]
fn real_ruff_reads_project_config_formats_stdin_and_supports_ranges() {
let temp = tempfile::tempdir().unwrap();
let tools = Tools::new(&temp.path().join("app-data"));
if tools.info("ruff", &[]).unwrap().status == ide_tools::ToolStatus::Missing {
eprintln!("SKIP real Ruff: not on PATH");
return;
}
let project = temp.path().join("project with spaces");
std::fs::create_dir_all(&project).unwrap();
std::fs::write(project.join("pyproject.toml"), "[tool.ruff.format]\nquote-style = \"single\"\n").unwrap();
let path = project.join("example.py");
let input = "def greet(name):\n return {\"message\": name}\n";
let output = format(&tools, &path, input, None, None).unwrap();
assert_eq!(output, "def greet(name):\n return {'message': name}\n");
assert!(!path.exists(), "formatting must never write the file");
let range = formatter::FormatRange {
start_offset: 0,
end_offset: 0,
start_line: 2,
start_col: 1,
end_line: 2,
end_col: 100,
};
let mixed = "def untouched( a,b ):\n return {\"message\": name}\n";
let output = format(&tools, &path, mixed, Some(range), None).unwrap();
assert!(output.starts_with("def untouched( a,b ):"), "{output}");
assert!(output.contains("return {'message': name}"), "{output}");
let shared = temp.path().join("shared config");
std::fs::create_dir_all(&shared).unwrap();
let shared_config = shared.join("ruff.toml");
std::fs::write(&shared_config, "[format]\nquote-style = \"double\"\n").unwrap();
std::fs::remove_file(project.join("pyproject.toml")).unwrap();
let output = format(&tools, &path, input, None, Some(&shared_config)).unwrap();
assert_eq!(output, "def greet(name):\n return {\"message\": name}\n");
let invalid = "def broken(:\n pass\n";
let error = format(&tools, &path, invalid, None, Some(&shared_config)).unwrap_err();
assert!(error.message.contains("Failed to parse"), "{}", error.message);
eprintln!(
"PASS: real Ruff config discovery, custom config file, stdin formatting, quote style, range, invalid syntax diagnostics, no disk writes"
);
}
+205 -112
View File
@@ -9,88 +9,44 @@ use std::collections::{HashMap, HashSet};
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
enum TextFilter {
/// Commits `git log --grep` selected, or whose id starts with `hash`.
Selected {
ids: HashSet<ObjectId>,
hash: Option<String>,
},
/// Case-insensitive non-ASCII text: git folds case only for ASCII under the C locale.
Folded(String),
Plain { needle: String, match_case: bool },
Regex(regex_lite::Regex),
}
impl TextFilter {
fn new(
r: &Repo,
scopes: &[String],
q: &LogQuery,
text: &str,
cancel: &CancellationToken,
) -> IdeResult<Option<Self>> {
fn new(text: &str, regex: bool, match_case: bool) -> IdeResult<Option<Self>> {
let text = text.trim();
if text.is_empty() {
return Ok(None);
}
if q.text_regex {
if regex {
let re = regex_lite::RegexBuilder::new(text)
.case_insensitive(!q.text_match_case)
.case_insensitive(!match_case)
.multi_line(true)
.build()
.map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?;
return Ok(Some(Self::Regex(re)));
}
if !q.text_match_case && !text.is_ascii() {
return Ok(Some(Self::Folded(text.to_lowercase())));
}
let mut filter = vec!["--fixed-strings".to_string(), format!("--grep={text}")];
if !q.text_match_case {
filter.push("--regexp-ignore-case".into());
}
let ids = git_select(r, scopes, &filter, &[], q.first_parent, cancel)?;
let hash = (text.len() >= 4 && text.bytes().all(|b| b.is_ascii_hexdigit())).then(|| text.to_ascii_lowercase());
Ok(Some(Self::Selected { ids, hash }))
let needle = if match_case { text.to_string() } else { text.to_lowercase() };
Ok(Some(Self::Plain { needle, match_case }))
}
fn matches(&self, repo: &gix::Repository, index: &mut crate::log_index::LogIndex, id: ObjectId) -> IdeResult<bool> {
Ok(match self {
Self::Regex(re) => re.is_match(&index.message(repo, id)?),
Self::Folded(needle) => index.message(repo, id)?.to_lowercase().contains(needle.as_str()),
Self::Selected { ids, hash } => {
ids.contains(&id) || hash.as_ref().is_some_and(|h| id.to_string().starts_with(h.as_str()))
fn matches(&self, message: &str, id: &ObjectId) -> bool {
match self {
Self::Regex(re) => re.is_match(message),
Self::Plain { needle, match_case } => {
let hash = needle.len() >= 4 && needle.bytes().all(|b| b.is_ascii_hexdigit());
if hash && id.to_string().starts_with(&needle.to_ascii_lowercase()) {
return true;
}
if *match_case {
message.contains(needle.as_str())
} else {
message.to_lowercase().contains(needle.as_str())
}
}
})
}
}
/// Commits git selects from each scope with `filter`. Path limiting uses git's history
/// simplification and, when the repository has them, changed-path Bloom filters.
fn git_select(
r: &Repo,
scopes: &[String],
filter: &[String],
paths: &[String],
first_parent: bool,
cancel: &CancellationToken,
) -> IdeResult<HashSet<ObjectId>> {
let mut ids = HashSet::new();
for scope in scopes {
let mut args =
vec!["log".to_string(), "--format=%H".into(), "--no-show-signature".into(), "--no-follow".into()];
if first_parent {
args.push("--first-parent".into());
}
args.extend(filter.iter().cloned());
args.push("--end-of-options".into());
args.extend(scope.split_whitespace().map(str::to_string));
args.push("--".into());
args.extend(paths.iter().map(|p| format!(":(literal){p}")));
let output =
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
for line in output.stdout_str().lines() {
ids.insert(ObjectId::from_hex(line.as_bytes()).map_err(gx)?);
}
}
Ok(ids)
}
pub(crate) fn log(r: &Repo, q: &LogQuery, cancel: &CancellationToken) -> IdeResult<Vec<Commit>> {
@@ -217,71 +173,87 @@ fn log_indexed(
} else {
None
};
let scopes = if let Some(range) = range {
vec![range.to_string()]
} else {
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
}
scopes
};
let mut history_paths = HashMap::new();
let path_ids = if paths.is_empty() {
None
} else if q.follow && paths.len() == 1 {
} else {
let mut ids = HashSet::new();
let rel = &paths[0];
let mut args = vec![
"log".to_string(),
"--format=%x00%H".into(),
"--name-status".into(),
"-z".into(),
"--follow".into(),
"--topo-order".into(),
];
if q.first_parent {
args.push("--first-parent".into());
}
args.push("--end-of-options".into());
for scope in &scopes {
let mut args = args.clone();
args.extend(scope.split_whitespace().map(str::to_string));
args.extend(["--".into(), rel.clone()]);
let output =
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
let text = output.stdout_str();
let mut tokens = text.split('\0').peekable();
let mut current_path = rel.clone();
while let Some(token) = tokens.next() {
let token = token.trim_start_matches('\n');
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
ids.insert(id);
history_paths.insert(id, current_path.clone());
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
&& new == current_path
{
current_path = old.to_string();
if q.follow && paths.len() == 1 {
let rel = &paths[0];
let mut args = vec![
"log".to_string(),
"--format=%x00%H".into(),
"--name-status".into(),
"-z".into(),
"--follow".into(),
"--topo-order".into(),
];
if q.first_parent {
args.push("--first-parent".into());
}
args.push("--end-of-options".into());
let scopes = if let Some(range) = range {
vec![range.to_string()]
} else {
let mut scopes = ranges.iter().map(|s| s.to_string()).collect::<Vec<_>>();
if !tips.is_empty() {
scopes.push(tips.iter().map(ToString::to_string).collect::<Vec<_>>().join(" "));
}
scopes
};
for scope in scopes {
let mut args = args.clone();
args.extend(scope.split_whitespace().map(str::to_string));
args.extend(["--".into(), rel.clone()]);
let output = crate::cli::run_cancellable(
r.root(),
&args.iter().map(String::as_str).collect::<Vec<_>>(),
cancel,
)?;
let text = output.stdout_str();
let mut tokens = text.split('\0').peekable();
let mut current_path = rel.clone();
while let Some(token) = tokens.next() {
let token = token.trim_start_matches('\n');
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
ids.insert(id);
history_paths.insert(id, current_path.clone());
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
&& new == current_path
{
current_path = old.to_string();
}
} else if ["A", "D", "M", "T", "U"].contains(&token) {
tokens.next();
}
} else if ["A", "D", "M", "T", "U"].contains(&token) {
tokens.next();
}
}
} else {
let starts = range_ids.as_ref().cloned().unwrap_or_else(|| tips.clone());
ids.extend(
PathWalk::new(&repo, starts, paths.clone(), q.first_parent, cancel, index)?
.map(|item| item.map(|(id, _)| id))
.collect::<IdeResult<HashSet<_>>>()?,
);
}
Some(ids)
} else {
Some(git_select(r, &scopes, &[], &paths, q.first_parent, cancel)?)
};
let authors: Vec<_> =
q.author.iter().chain(&q.authors).map(|a| a.trim().to_lowercase()).filter(|a| !a.is_empty()).collect();
let user_candidates = if q.authors_exact && !authors.is_empty() { index.user_candidates(&authors) } else { None };
let path_candidates = if !q.follow && !paths.is_empty() { index.path_candidates(&paths) } else { None };
let text_candidates = q.text.as_deref().filter(|_| !q.text_regex).and_then(|s| index.message_candidates(s));
let mut visibility = Visibility {
speed_refs: &decorations,
text_candidates,
user_candidates,
path_candidates,
query: q,
path_ids,
range_ids: range_ids.as_ref().map(|ids| ids.iter().copied().collect()),
authors,
text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(),
text: q.text.as_deref().map(|t| TextFilter::new(t, q.text_regex, q.text_match_case)).transpose()?.flatten(),
matches: HashMap::new(),
projected: HashMap::new(),
cancel,
@@ -612,6 +584,9 @@ struct Visibility<'a> {
range_ids: Option<HashSet<ObjectId>>,
authors: Vec<String>,
text: Option<TextFilter>,
text_candidates: Option<HashSet<String>>,
user_candidates: Option<HashSet<String>>,
path_candidates: Option<HashSet<String>>,
matches: HashMap<ObjectId, bool>,
projected: HashMap<ObjectId, Vec<ObjectId>>,
cancel: &'a CancellationToken,
@@ -627,7 +602,10 @@ impl Visibility<'_> {
if let Some(shown) = self.matches.get(&id) {
return Ok(*shown);
}
let shown = if !repo.has_object(id)
let indexed = self.index.indexed(&id.to_string());
let shown = if (indexed && self.user_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|| (indexed && self.path_candidates.as_ref().is_some_and(|ids| !ids.contains(&id.to_string())))
|| !repo.has_object(id)
|| self.path_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
|| self.range_ids.as_ref().is_some_and(|ids| !ids.contains(&id))
{
@@ -670,7 +648,15 @@ impl Visibility<'_> {
}
}))
&& match &self.text {
Some(text) => text.matches(repo, self.index, id)?,
Some(text) => {
self.text_candidates.as_ref().is_none_or(|ids| {
ids.contains(&id.to_string())
|| !self.index.indexed(&id.to_string())
|| id.to_string().starts_with(
self.query.text.as_deref().unwrap_or("").trim().to_ascii_lowercase().as_str(),
)
}) && text.matches(&self.index.message(repo, id)?, &id)
}
None => true,
}
};
@@ -879,6 +865,113 @@ pub(crate) fn decorations(repo: &gix::Repository) -> IdeResult<HashMap<ObjectId,
Ok(map)
}
/// Commits touching a path, newest first, with git's default history simplification:
/// a commit is shown when its entry for the path differs from every parent, and at a
/// merge that kept one parent's version only that parent's line of history is followed.
struct PathWalk<'r> {
repo: &'r gix::Repository,
paths: Vec<String>,
queue: std::collections::BinaryHeap<(i64, u64, ObjectId)>,
seen: HashSet<ObjectId>,
index: &'r mut crate::log_index::LogIndex,
seq: u64,
first_parent: bool,
cancel: &'r CancellationToken,
shallow: HashSet<ObjectId>,
}
impl<'r> PathWalk<'r> {
fn new(
repo: &'r gix::Repository,
tips: Vec<ObjectId>,
paths: Vec<String>,
first_parent: bool,
cancel: &'r CancellationToken,
index: &'r mut crate::log_index::LogIndex,
) -> IdeResult<Self> {
let mut walk = PathWalk {
repo,
paths,
queue: Default::default(),
seen: Default::default(),
index,
seq: 0,
first_parent,
cancel,
shallow: repo.shallow_commits().map_err(gx)?.map(|ids| ids.iter().copied().collect()).unwrap_or_default(),
};
for tip in tips {
walk.push(tip)?;
}
Ok(walk)
}
fn push(&mut self, id: ObjectId) -> IdeResult<()> {
if self.cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
if !self.seen.insert(id) || !self.repo.has_object(id) {
return Ok(());
}
let time = self.repo.find_commit(id).map_err(gx)?.time().map(|t| t.seconds).unwrap_or(0);
// Max-heap on time; among equal times, earlier insertion first.
self.seq += 1;
self.queue.push((time, u64::MAX - self.seq, id));
Ok(())
}
fn step(&mut self) -> IdeResult<Option<(ObjectId, Vec<ObjectId>)>> {
while let Some((_, _, id)) = self.queue.pop() {
let commit = self.repo.find_commit(id).map_err(gx)?;
let parents: Vec<ObjectId> = commit.parent_ids().map(|p| p.detach()).collect();
// Parents cut off by a shallow boundary are treated as absent.
let present: Vec<ObjectId> = parents
.iter()
.copied()
.take(if self.shallow.contains(&id) { 0 } else { usize::MAX })
.filter(|p| self.repo.has_object(p))
.take(if self.first_parent { 1 } else { usize::MAX })
.collect();
let mine = self
.paths
.iter()
.map(|path| self.index.path_entry(self.repo, id, path))
.collect::<IdeResult<Vec<_>>>()?;
let mut same_parent = None;
for p in &present {
let theirs = self
.paths
.iter()
.map(|path| self.index.path_entry(self.repo, *p, path))
.collect::<IdeResult<Vec<_>>>()?;
if theirs == mine {
same_parent = Some(*p);
break;
}
}
match same_parent {
Some(p) => self.push(p)?,
None => {
for p in &present {
self.push(*p)?;
}
if !present.is_empty() || mine.iter().any(Option::is_some) {
return Ok(Some((id, parents)));
}
}
}
}
Ok(None)
}
}
impl Iterator for PathWalk<'_> {
type Item = IdeResult<(ObjectId, Vec<ObjectId>)>;
fn next(&mut self) -> Option<Self::Item> {
self.step().transpose()
}
}
pub(crate) fn commit_files(r: &Repo, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let repo = r.gix()?;
Ok(first_parent_changes(&repo, rev)?.files)
+62 -18
View File
@@ -162,6 +162,14 @@ pub struct LogDetails {
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")]
@@ -302,18 +310,6 @@ pub struct ChangedFile {
pub old_path: Option<String>,
}
/// Added and deleted lines of one file between two revisions (`git diff --numstat`).
/// A rename keeps the counts of the content change; a pure rename is `0`/`0`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct FileLineStats {
/// Path after the change (the only path, when the file was not renamed).
pub path: String,
pub old_path: Option<String>,
pub added: u32,
pub removed: u32,
}
/// A file changed across several commits: its content at `before` ↔ at `after`.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
@@ -676,6 +672,7 @@ pub struct Repo {
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>>,
}
@@ -847,6 +844,59 @@ impl Repo {
.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())
}
@@ -1260,12 +1310,6 @@ impl Repo {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_revisions(self, from, to)
}
/// Added and deleted lines between `from` and `to`. `paths` limits the diff to those
/// repository-relative paths (both names of a rename); empty diffs every file.
pub fn diff_numstat(&self, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
ops::diff_numstat(self, from, to, paths)
}
/// Tracked files whose working-tree content differs from `rev`.
pub fn diff_with_worktree(&self, rev: &str) -> IdeResult<Vec<ChangedFile>> {
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
+159 -2
View File
@@ -6,7 +6,7 @@ use crate::repo::{gx, to_string};
use gix::ObjectId;
use gix::bstr::ByteSlice;
use ide_core::IdeResult;
use redb::{Database, ReadableDatabase, TableDefinition};
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::{Arc, Mutex, OnceLock, Weak};
@@ -14,6 +14,9 @@ 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>,
@@ -33,6 +36,9 @@ pub(crate) struct LogIndex {
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>>,
@@ -86,6 +92,91 @@ impl LogIndex {
}
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;
@@ -181,7 +272,11 @@ impl LogIndex {
}
pub fn flush(&mut self) {
if self.dirty.is_empty() && self.dirty_messages.is_empty() {
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>> {
@@ -196,12 +291,29 @@ impl LogIndex {
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(())
})();
@@ -209,7 +321,52 @@ impl LogIndex {
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)
}
}
+1 -52
View File
@@ -3,7 +3,7 @@
use crate::cli::{self, run};
use crate::repo::{gx, resolve};
use crate::{ChangeKind, ChangedFile, FileLineStats, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use crate::{ChangeKind, ChangedFile, MergeSides, RebaseAction, RebaseEntry, Repo, ResetMode};
use ide_core::{CancellationToken, IdeError, IdeResult};
/// The in-progress operation git can continue / abort / skip.
@@ -394,44 +394,6 @@ pub(crate) fn parse_numstat(text: &str) -> Vec<(String, u32, u32)> {
.collect()
}
/// Added and deleted lines between two revisions. `paths` limits the diff (pass both names of
/// a rename); an empty list, or one too long for the command line, diffs every file.
pub(crate) fn diff_numstat(r: &Repo, from: &str, to: &str, paths: &[String]) -> IdeResult<Vec<FileLineStats>> {
check_commits(&[from.to_string(), to.to_string()])?;
let mut args = vec!["diff", "--numstat", "-z", "-M", "--no-ext-diff", "--no-textconv", from, to];
// Windows command lines are short; past this, the unfiltered diff still contains these paths.
let limited = !paths.is_empty() && paths.iter().map(|p| p.len() + 1).sum::<usize>() <= 16_000;
if limited {
args.push("--");
args.extend(paths.iter().filter(|p| !p.is_empty()).map(String::as_str));
}
let out = run(r.root(), &args, None)?;
Ok(parse_numstat_z(&out.stdout_str()))
}
/// `git diff --numstat -z -M`: `added\tremoved\tpath\0`, or `added\tremoved\0old\0new\0` for a rename.
/// Binary files (`-`) count as 0.
pub(crate) fn parse_numstat_z(text: &str) -> Vec<FileLineStats> {
let mut parts = text.split('\0').filter(|s| !s.is_empty());
let mut out = Vec::new();
while let Some(rec) = parts.next() {
let mut fields = rec.splitn(3, '\t');
let added = fields.next().unwrap_or("0").parse().unwrap_or(0);
let removed = fields.next().unwrap_or("0").parse().unwrap_or(0);
let (path, old_path) = if let Some(path) = fields.next().filter(|p| !p.is_empty()) {
(path.to_owned(), None)
} else {
let (Some(old), Some(new)) = (parts.next(), parts.next()) else { break };
(new.to_owned(), Some(old.to_owned()))
};
if !path.is_empty() {
out.push(FileLineStats { path, old_path, added, removed });
}
}
out.sort_by(|a, b| a.path.cmp(&b.path).then(a.old_path.cmp(&b.old_path)));
out
}
/// Files that differ between two revisions (Update Project's "N files updated").
pub(crate) fn diff_revisions(r: &Repo, from: &str, to: &str) -> IdeResult<Vec<ChangedFile>> {
check_commits(&[from.to_string(), to.to_string()])?;
@@ -495,17 +457,4 @@ mod tests {
vec![("src/a.rs".to_owned(), 3, 1), ("img.png".to_owned(), 0, 0), ("dir/b c.txt".to_owned(), 0, 7)]
);
}
#[test]
fn numstat_z_parsing_counts_renames_and_binaries_as_zero() {
let text = "1\t0\timg.bin\u{0}2\t1\u{0}file.txt\u{0}renamed.txt\u{0}-\t-\tbin.dat\u{0}";
assert_eq!(
parse_numstat_z(text),
vec![
FileLineStats { path: "bin.dat".into(), old_path: None, added: 0, removed: 0 },
FileLineStats { path: "img.bin".into(), old_path: None, added: 1, removed: 0 },
FileLineStats { path: "renamed.txt".into(), old_path: Some("file.txt".into()), added: 2, removed: 1 },
]
);
}
}
+1
View File
@@ -65,6 +65,7 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
inner: std::sync::Mutex::new(cache),
ignored: Default::default(),
log_index: Default::default(),
index_progress: Default::default(),
log_signatures: Default::default(),
}))
}
+2 -28
View File
@@ -99,6 +99,8 @@ fn lazy_summaries_seek_and_index_survive_refresh_and_restart() {
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();
@@ -434,31 +436,3 @@ fn multiple_paths_are_simplified_together_at_merges() {
assert_eq!(log[0].graph_parents.as_ref().unwrap().len(), 2);
}
}
#[test]
fn paths_are_literal_and_text_matches_bodies_or_hash_prefixes() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let star = commit(dir, "[a]", "star\n\nBody Needle");
let plain = commit(dir, "a", "plain");
let repo = Repo::discover(dir).unwrap().unwrap();
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(LogQuery { path: Some("[a]".into()), ..Default::default() }), vec![star.clone()]);
assert_eq!(ids(LogQuery { text: Some("body needle".into()), ..Default::default() }), vec![star.clone()]);
assert!(ids(LogQuery { text: Some("body needle".into()), text_match_case: true, ..Default::default() }).is_empty());
assert_eq!(ids(LogQuery { text: Some(plain[..7].to_uppercase()), ..Default::default() }), vec![plain]);
}
#[test]
fn case_insensitive_text_folds_non_ascii() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
git(dir, &["init", "-q"]);
let hit = commit(dir, "a", "Ärger ПРИВЕТ");
commit(dir, "a", "other");
let repo = Repo::discover(dir).unwrap().unwrap();
let ids = |q: LogQuery| repo.log(&q).unwrap().into_iter().map(|c| c.id).collect::<Vec<_>>();
assert_eq!(ids(LogQuery { text: Some("ärger привет".into()), ..Default::default() }), vec![hit.clone()]);
assert!(ids(LogQuery { text: Some("ärger".into()), text_match_case: true, ..Default::default() }).is_empty());
}
-36
View File
@@ -594,42 +594,6 @@ fn commit_files_kinds() {
assert!(repo.commit_files("HEAD").unwrap().len() == 4);
}
/// Line counts follow the same first-parent diff as the commit's file list, including renames.
#[test]
fn diff_numstat_counts_added_deleted_and_renames() {
let fx = Fixture::new();
fx.write("keep.txt", "a\nb\nc\n");
fx.write("mod.txt", "m\n");
fx.write("old.txt", "a fairly long line of content\nanother line\nand a third\n");
let root = fx.commit_all("root");
fx.write("mod.txt", "m\nmore\n");
std::fs::remove_file(fx.path("keep.txt")).unwrap();
fx.write("fresh.txt", "n1\nn2\n");
fx.git(&["mv", "old.txt", "new.txt"]);
fx.write("new.txt", "a fairly long line of content\nanother line\nchanged\n");
let c = fx.commit_all("changes");
let repo = fx.repo();
let stats = repo.diff_numstat(&root, &c, &[]).unwrap();
let find = |p: &str| stats.iter().find(|f| f.path == p).unwrap_or_else(|| panic!("{p}: {stats:#?}"));
assert_eq!((find("mod.txt").added, find("mod.txt").removed), (1, 0));
assert_eq!((find("keep.txt").added, find("keep.txt").removed), (0, 3));
assert_eq!((find("fresh.txt").added, find("fresh.txt").removed), (2, 0));
assert_eq!(find("new.txt").old_path.as_deref(), Some("old.txt"));
assert_eq!((find("new.txt").added, find("new.txt").removed), (1, 1));
let only = repo.diff_numstat(&root, &c, &["mod.txt".into()]).unwrap();
assert_eq!(only.iter().map(|f| f.path.as_str()).collect::<Vec<_>>(), vec!["mod.txt"]);
// The log passes both names, which is what lets git still report the rename.
let renamed = repo.diff_numstat(&root, &c, &["old.txt".into(), "new.txt".into()]).unwrap();
assert_eq!(renamed.len(), 1, "{renamed:#?}");
assert_eq!(renamed[0].old_path.as_deref(), Some("old.txt"));
assert_eq!((renamed[0].added, renamed[0].removed), (1, 1));
let created = repo.diff_numstat("4b825dc642cb6eb9a060e54bf8d69288fbee4904", &root, &["mod.txt".into()]).unwrap();
assert_eq!((created[0].added, created[0].removed), (1, 0));
}
/// IDEA's multi-commit Git Log selection: per file, before the first commit touching it ↔
/// after the last one, contiguous or not.
#[test]
+5 -28
View File
@@ -537,15 +537,9 @@ impl Documents {
pub fn edit(&self, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
self.with_doc(doc, |st| {
if st.read_only {
tracing::warn!("edit rejected: {} is read-only", st.path.display());
return Err(read_only_error(&st.path));
}
let (applied, edits) = st.doc.apply_detailed(base_version, &changes).inspect_err(|e| {
// A stale version is routine (the UI rebases); anything else makes the editor drop the edit.
if e.kind != ErrorKind::OutOfSync {
tracing::warn!("edit of {} rejected: {e}", st.path.display());
}
})?;
let (applied, edits) = st.doc.apply_detailed(base_version, &changes)?;
self.set_dirty(doc, st, true);
self.notify_changed(doc, st, &applied, &edits);
Ok(st.doc.version())
@@ -868,18 +862,17 @@ impl Documents {
.then(|| st.path.clone())
})
.collect();
self.reload_from_disk(&paths, force_clean, true);
self.reload_from_disk(&paths, force_clean);
}
/// Re-reads from disk after an external change. Clean documents reload
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
/// Changes matching our own last save are ignored.
pub fn on_disk_changed(&self, paths: &[PathBuf]) {
self.reload_from_disk(paths, false, false);
self.reload_from_disk(paths, false);
}
/// `dedup`: blind scans skip files whose `(mtime, length)` stamp still matches.
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool, dedup: bool) {
fn reload_from_disk(&self, paths: &[PathBuf], force_clean: bool) {
for path in paths {
let Some(id) = self.by_path(path) else { continue };
let result = self.with_doc(id, |st| {
@@ -888,23 +881,7 @@ impl Documents {
}
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
let mtime = ide_vfs::mtime_ms(&meta);
let unchanged = mtime == st.disk_mtime && meta.len() == st.disk_len;
// Blind scans trust the stamp. OS events are authoritative, but they also
// echo our own saves: a clean document whose stamp matches is re-checked by
// bytes — an external write that preserved both length and mtime differs
// from what saving this buffer would write, our own save's echo does not.
// Native-to-ascii properties documents and dirty documents keep the stamp
// dedup: the former's disk bytes legitimately differ from the buffer text,
// the latter warn once per disk state.
if unchanged
&& (!force_clean || st.dirty)
&& (dedup
|| st.dirty
|| st.native_to_ascii
|| ide_vfs::encode(&st.doc.text_for_save(), &st.encoding, st.has_bom)
.map(|bytes| bytes == std::fs::read(&st.path).unwrap_or_default())
.unwrap_or(true))
{
if mtime == st.disk_mtime && meta.len() == st.disk_len && (!force_clean || st.dirty) {
return Ok(());
}
if st.dirty {
Loaded 100 of 405 files, more files were not shown because too many files have changed in this diff. Show more