Compare commits

..
Author SHA1 Message Date
l0ng-ai 839e5d72fd fix(project): open a window's project once when opens race
Two opens of one window could each find no current project and build a full project (index, language servers, watchers); the second replaced the first without closing it, leaving two jdtls on one workspace. open_project and open_light now hold a per-window lock from the check to the insert.
2026-11-05 16:05:39 +08:00
225 changed files with 1259 additions and 8697 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
-1
View File
@@ -6379,7 +6379,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"windows",
"windows-sys 0.61.2",
]
+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 -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;
}
-4
View File
@@ -722,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)]
-15
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);
+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());
}
}
-7
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();
@@ -353,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();
+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();
-16
View File
@@ -198,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();
@@ -215,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) {
+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
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,
@@ -1200,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
});
-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`.
#[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\necho \"$XDEBUG_MODE $*\" >> runs.log\n\
sleep \"$(cat pause 2>/dev/null || echo 0)\"\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();
}
}
+2 -16
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;
@@ -420,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,
@@ -468,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();
@@ -807,19 +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));
}
#[test]
fn programs_run_only_as_approved() {
let tmp = tempfile::tempdir().unwrap();
+1 -11
View File
@@ -422,8 +422,7 @@ struct IdeDir {
plugins: PathBuf,
}
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first, one
/// per product: its newest version's (an upgrade leaves the older ones behind).
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
let (config_root, data_root) = match layout {
@@ -463,8 +462,6 @@ fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>)
}
}
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
let mut products = std::collections::HashSet::new();
out.retain(|ide| products.insert(ide.product.clone()));
out
}
@@ -787,13 +784,6 @@ mod tests {
};
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
// An upgrade leaves the older version's folder behind: only the newest of a product counts.
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2024.3"));
mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10"));
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.10");
std::fs::remove_dir(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.10")).unwrap();
let dirs = ide_dirs(Layout::Mac, home, |_| None);
assert_eq!(dirs.len(), 1);
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
+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 -3
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"
+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");
+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": {}
})
}
-5
View File
@@ -234,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();
+5 -21
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(),
@@ -1173,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));
@@ -1269,17 +1254,16 @@ impl LspManager {
) -> IdeResult<Vec<Location>> {
let mut out = Outcome::new();
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
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;
-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()
}
+1 -74
View File
@@ -1009,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())]);
@@ -3405,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> {
-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 {
-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();
-33
View File
@@ -691,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));
@@ -1308,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:?}");
}
}
+2 -103
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)]
@@ -141,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 {
@@ -379,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
+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() {
+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
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")]
@@ -664,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>>,
}
@@ -835,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())
}
+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
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());
}
+1 -7
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())
-62
View File
@@ -1,7 +1,6 @@
//! Window grouping is independent of a window's project or workspace.
use ide_core::{IdeError, IdeResult};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Platform adapters supply the system's tab preference and active project state.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -118,43 +117,6 @@ impl WindowGroups {
}
}
/// One entry of the window session reopened at the next start (`window-groups.json`): a
/// window group, or a single project window (one project).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SavedWindows {
pub projects: Vec<PathBuf>,
/// Index into `projects` of the group's selected tab.
pub selected: usize,
}
/// The project windows open now, to reopen at the next start: every group with its selected
/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a
/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped
/// window has no project yet (a restored member still opening): keep the previous session.
pub fn session(
groups: &[WindowGroup],
windows: &[String],
project_of: impl Fn(&str) -> Option<PathBuf>,
) -> Option<Vec<SavedWindows>> {
let mut saved = Vec::new();
for group in groups {
let projects: Option<Vec<PathBuf>> = group.members.iter().map(|label| project_of(label)).collect();
let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0);
saved.push(SavedWindows { projects: projects?, selected });
}
for label in windows {
if groups.iter().any(|g| g.members.contains(label)) {
continue;
}
if let Some(project) = project_of(label)
&& !saved.iter().any(|s| s.projects.contains(&project))
{
saved.push(SavedWindows { projects: vec![project], selected: 0 });
}
}
Some(saved)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -224,28 +186,4 @@ mod tests {
groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap();
assert_eq!(groups.group("a").unwrap().members[0], "d");
}
#[test]
fn the_session_keeps_groups_and_every_ungrouped_project_window() {
let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }];
let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]);
let project_of = |label: &str| match label {
"main" => Some(PathBuf::from("/a")),
"project-1" => Some(PathBuf::from("/b")),
"project-2" => Some(PathBuf::from("/c")),
"project-3" => Some(PathBuf::from("/d")),
// The welcome screen or LightEdit.
_ => None,
};
assert_eq!(
session(&groups, &windows, project_of).unwrap(),
[
SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 },
SavedWindows { projects: vec!["/a".into()], selected: 0 },
SavedWindows { projects: vec!["/d".into()], selected: 0 },
]
);
// A grouped window still opening its project: keep the previous session.
assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None);
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 377 KiB

+8 -20
View File
@@ -130,15 +130,14 @@ install it from disk.
Only port themes whose licence allows redistribution, keep their licence file,
and credit the original authors in `authors` and `description`.
### From JetBrains IDEs (automatic)
### From IntelliJ IDEA (automatic)
To use a theme you already have in a JetBrains IDE, open **Settings › Plugins**, gear
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
menu:
- **Import Theme from JetBrains IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, one row per plugin of
each IDE, the plugins of the themes the IDEs use first. It reads the settings
folder of each IDE's newest version: `~/Library/Application Support/JetBrains`
- **Import Theme from IntelliJ IDE…** lists the theme plugins installed in the
JetBrains IDEs (and Android Studio) on this computer, the theme each IDE uses
first. It reads their settings folders: `~/Library/Application Support/JetBrains`
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
`~/.local/share/JetBrains` (Linux).
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
@@ -147,8 +146,8 @@ menu:
The whole plugin becomes one OpenIDEA theme plugin, `intellij.<plugin id>`, with
each of its themes and their editor schemes; it is installed through the usual
install page, and the theme in use stays as it is: choose one of its themes in
**Settings › Appearance › Theme**. Importing the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
install page and the chosen theme (otherwise the first) is applied. Importing
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
`plugins.importInstalledIntellijTheme`). The conversion is the one described
below.
@@ -324,7 +323,7 @@ a token.
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
### Surfaces (UI theme)
@@ -407,10 +406,6 @@ a token.
| `--diff-modified` | `#385570` | `#c2d8f2` |
| `--diff-inserted` | `#294436` | `#bee6be` |
| `--diff-deleted` | `#484a4a` | `#d6d6d6` |
| `--diff-modified-stripe` | `#43698d` | `#b6d2f2` |
| `--diff-inserted-stripe` | `#447152` | `#aadeaa` |
| `--diff-deleted-stripe` | `#656e76` | `#c8c8c8` |
| `--diff-conflict-stripe` | `#8f5247` | `#ffc8bd` |
### Syntax (editor scheme)
@@ -434,13 +429,6 @@ a token.
| `--syn-param` | `#bcbec4` | `#080808` |
| `--syn-property` | `#c77dbb` | `#871094` |
| `--syn-constant` | `#c77dbb` | `#871094` |
| `--syn-python-local-variable` | `var(--syn-variable)` | `var(--syn-variable)` |
| `--syn-python-global-variable` | `var(--syn-global)` | `var(--syn-global)` |
| `--syn-python-instance-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-class-attribute` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-constant` | `var(--syn-constant)` | `var(--syn-constant)` |
| `--syn-python-property` | `var(--syn-property)` | `var(--syn-property)` |
| `--syn-python-type-parameter` | `var(--syn-type)` | `var(--syn-type)` |
| `--syn-builtin` | `#8888c6` | `#000080` |
| `--syn-self` | `#94558d` | `#94558d` |
| `--syn-keyword-arg` | `#aa4926` | `#660099` |
-90
View File
@@ -1,90 +0,0 @@
/**
* #1539: committing a subset of the changed files must not check the ones left out again.
* Git is stubbed at the IPC layer, like `commit.spec.ts`.
*/
import type { Page } from "@playwright/test";
import { expect, ipcCalls, stubDesktop, test } from "./fixtures";
const ROOT = "/dev/acme-api";
const NAMES = Array.from({ length: 20 }, (_, i) => `src/f${i}.ts`);
/** Twenty modified tracked files; `vcs_stage` + `vcs_commit` take the staged ones out of the status. */
async function stubTwentyChanges(page: Page) {
await stubDesktop(page);
await page.evaluate(
async ({ root, names }) => {
const staged = new Set<string>();
const committed = new Set<string>();
const all = names.map((rel) => ({
path: `${root}/${rel}`,
relPath: rel,
staged: null,
unstaged: "modified",
renamedFrom: null,
}));
const h = window.__ipc.handlers;
h.vcs_roots = () => [{ root, operation: null, head: "main", headCommit: "1111111" }];
h.vcs_branch_info = () => ({
head: "main",
headCommit: "1111111",
upstream: null,
ahead: 0,
behind: 0,
operation: null,
});
h.vcs_status = () => all.filter((f) => !committed.has(f.path));
h.vcs_stage = (args) => {
for (const path of args.paths as string[]) staged.add(path);
return null;
};
h.vcs_unstage = () => null;
h.vcs_commit = () => {
for (const path of staged) committed.add(path);
staged.clear();
return "2222222";
};
const vcsModule = "/src/features/vcs/store.ts";
const { useVcs, refreshNow } = await import(/* @vite-ignore */ vcsModule);
useVcs.setState({ available: true, root, repo: root, branchesLoaded: false, stashesLoaded: false });
await refreshNow();
},
{ root: ROOT, names: NAMES },
);
}
test("changes left out of a commit stay unchecked (#1539)", async ({ workbench: page }, testInfo) => {
await stubTwentyChanges(page);
await page.keyboard.press("Meta+k");
const panel = page.getByRole("region", { name: "Commit", exact: true });
const changes = panel.getByRole("tree", { name: "Changes" });
// Uncheck everything, then pick the 5 files to commit.
await changes.getByRole("checkbox", { name: "Include Changes in commit" }).click();
for (const name of NAMES.slice(0, 5)) {
await changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` }).click();
}
await expect(changes.getByRole("checkbox", { name: "Include f0.ts in commit" })).toHaveAttribute(
"aria-checked",
"true",
);
await panel.getByRole("textbox", { name: "Commit message" }).fill("Commit 5 of 20");
await panel.getByRole("button", { name: "Commit", exact: true }).click();
await expect(page.getByText(/5 files committed: Commit 5 of 20/)).toBeVisible();
expect((await ipcCalls(page, "vcs_stage")).flatMap((a) => a.paths as string[]).sort()).toEqual(
NAMES.slice(0, 5)
.map((rel) => `${ROOT}/${rel}`)
.sort(),
);
// The 15 files left out are still in the tree, unchecked: the next commit cannot take them by surprise.
const leftOut = changes.getByRole("checkbox", { name: "Include f19.ts in commit" });
await expect(leftOut).toBeVisible();
await page.screenshot({ path: testInfo.outputPath("commit-inclusion.png"), animations: "disabled" });
for (const name of NAMES.slice(5)) {
await expect(changes.getByRole("checkbox", { name: `Include ${name.split("/").pop()} in commit` })).toHaveAttribute(
"aria-checked",
"false",
);
}
});
-84
View File
@@ -1,84 +0,0 @@
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { syntaxHighlighting } from "@codemirror/language";
import { openideaHighlightStyle } from "../src/editor/core/cm/highlight";
import { findLanguageDescription, loadSupport } from "../src/editor/core/cm/languages";
import { semanticExtension } from "../src/editor/lsp/semantic";
import { lspDoc } from "../src/editor/lsp/lsp";
import { lspTheme } from "../src/editor/lsp/theme";
import { parseIcls } from "../src/platform/icls";
import { schemeCss } from "../src/platform/colorScheme";
import type { EditorDocContext } from "../src/editor/api";
const attributes = {
TEXT: { FOREGROUND: "ddeeff", BACKGROUND: "101820" },
"PY.KEYWORD": { FOREGROUND: "112233" },
"PY.FUNC_DEFINITION": { FOREGROUND: "224466" },
"PY.NESTED_FUNC_DEFINITION": { FOREGROUND: "335577" },
DEFAULT_FUNCTION_CALL: { FOREGROUND: "446688" },
DEFAULT_LINE_COMMENT: { FOREGROUND: "667788" },
DEFAULT_BLOCK_COMMENT: { FOREGROUND: "aabbcc" },
"PY.METHOD_CALL": { FOREGROUND: "557799" },
"PY.DOC_COMMENT": { FOREGROUND: "6688aa", EFFECT_TYPE: "1" },
"PY.STRING.B": { FOREGROUND: "7799bb", FONT_TYPE: "1" },
"PY.VALID_STRING_ESCAPE": { FOREGROUND: "88aacc" },
"PY.ANNOTATION": { FOREGROUND: "99bbdd", BACKGROUND: "182838" },
"PY.SELF_PARAMETER": { FOREGROUND: "aa7788" },
"PY.BUILTIN_NAME": { FOREGROUND: "bb8899" },
"PY.KEYWORD_ARGUMENT": { FOREGROUND: "cc99aa" },
OPENIDEA_pyLocalVariable: { FOREGROUND: "aa1101" },
OPENIDEA_pyGlobalVariable: { FOREGROUND: "aa2202" },
OPENIDEA_pyInstanceAttribute: { FOREGROUND: "aa3303" },
OPENIDEA_pyClassAttribute: { FOREGROUND: "aa4404" },
OPENIDEA_pyConstant: { FOREGROUND: "aa5505" },
OPENIDEA_pyProperty: { FOREGROUND: "aa6606" },
OPENIDEA_pyTypeParameter: { FOREGROUND: "aa7707" },
};
const xml = `<scheme name="Python" parent_scheme="Darcula"><attributes>${Object.entries(attributes)
.map(
([key, values]) =>
`<option name="${key}"><value>${Object.entries(values)
.map(([name, value]) => `<option name="${name}" value="${value}"/>`)
.join("")}</value></option>`,
)
.join("")}</attributes></scheme>`;
const style = document.createElement("style");
style.textContent =
schemeCss("custom-python", {}, null, { "custom-python": parseIcls(xml).scheme }) +
"body { margin: 24px; background: #101820; color: #ddeeff; } .cm-editor { font: 14px/1.6 monospace; }";
document.head.append(style);
const support = await loadSupport(findLanguageDescription("Python", "demo.py")!);
new EditorView({
parent: document.body,
state: EditorState.create({
doc: `MAX_SIZE: Final = 10
name = "global"
T = TypeVar("T")
def outer(x: T) -> T:
"""Syntax Highlighting Demo"""
def nested(y):
print(y + 1)
nested(x)
class Foo[T]:
count = 0
def __init__(self: Foo, value: T):
value = b"newline:\\n"
local = 1
self.instance_attr = value
print(local, MAX_SIZE, name, T)
self.run(whatever=1) # Comment
@property
def size(self) -> int:
return self.instance_attr
`,
extensions: [
support!,
syntaxHighlighting(openideaHighlightStyle),
lspTheme,
lspDoc.of({ language: "python" } as EditorDocContext),
...(new URLSearchParams(location.search).has("baseline") ? [] : [semanticExtension()]),
],
}),
});
-9
View File
@@ -1,9 +0,0 @@
<!doctype html>
<html>
<head>
<meta charset="UTF-8" />
</head>
<body>
<script type="module" src="./pythonHighlight.fixture.ts"></script>
</body>
</html>
-51
View File
@@ -1,51 +0,0 @@
import { expect, test } from "@playwright/test";
test("Python syntax supplements retain PyCharm colours without a language server", async ({ page }, info) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.goto("/e2e/pythonHighlight.html?baseline");
await expect(page.locator(".cm-content")).toBeVisible();
await page.screenshot({ path: info.outputPath("before.png") });
await page.goto("/e2e/pythonHighlight.html");
await expect(page.locator(".cm-sem-python-definition")).toHaveText("outer");
await expect(page.locator(".cm-sem-python-definition")).toHaveCSS("color", "rgb(34, 68, 102)");
await expect(page.locator(".cm-sem-python-nested")).toHaveCSS("color", "rgb(51, 85, 119)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("color", "rgb(102, 136, 170)");
await expect(page.locator(".cm-sem-python-docstring")).toHaveCSS("text-decoration-line", "none");
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("color", "rgb(119, 153, 187)");
await expect(page.locator(".cm-sem-python-bytes").first()).toHaveCSS("font-weight", "700");
await expect(page.locator(".cm-sem-python-escape")).toHaveCSS("color", "rgb(136, 170, 204)");
await expect(page.locator(".cm-sem-python-annotation").first()).toHaveCSS("color", "rgb(153, 187, 221)");
await expect(page.locator(".cm-sem-self").first()).toHaveCSS("color", "rgb(170, 119, 136)");
await expect(page.locator(".cm-sem-python-method-call")).toHaveCSS("color", "rgb(85, 119, 153)");
await expect(
page
.locator(".cm-line")
.filter({ hasText: "nested(x)" })
.locator(".cm-sem-function")
.filter({ hasText: /^nested$/ }),
).toHaveCSS("color", "rgb(68, 102, 136)");
await expect(
page
.locator(".cm-line")
.filter({ hasText: "# Comment" })
.locator("span")
.filter({ hasText: /^# Comment$/ }),
).toHaveCSS("color", "rgb(102, 119, 136)");
const print = page
.locator(".cm-sem-python-builtin")
.filter({ hasText: /^print$/ })
.first();
await expect(print).toHaveText("print");
await expect(print).toHaveCSS("color", "rgb(187, 136, 153)");
await expect(page.locator(".cm-sem-python-keyword-arg")).toHaveCSS("color", "rgb(204, 153, 170)");
await expect(page.locator(".cm-sem-python-local-variable").first()).toHaveCSS("color", "rgb(170, 17, 1)");
await expect(page.locator(".cm-sem-python-global-variable").first()).toHaveCSS("color", "rgb(170, 34, 2)");
await expect(page.locator(".cm-sem-python-instance-attribute").first()).toHaveCSS("color", "rgb(170, 51, 3)");
await expect(page.locator(".cm-sem-python-class-attribute").first()).toHaveCSS("color", "rgb(170, 68, 4)");
await expect(page.locator(".cm-sem-python-constant").first()).toHaveCSS("color", "rgb(170, 85, 5)");
await expect(page.locator(".cm-sem-python-property").first()).toHaveCSS("color", "rgb(170, 102, 6)");
await expect(page.locator(".cm-sem-python-type-parameter").first()).toHaveCSS("color", "rgb(170, 119, 7)");
expect(errors).toEqual([]);
await page.screenshot({ path: info.outputPath("python-highlighting.png") });
});
-5
View File
@@ -29,11 +29,6 @@ test("local terminal stores clipboard media and pastes one shell-quoted argument
try {
session.attach(container);
await session.start();
// xterm opens on a later frame, once the pane has a size.
const openDeadline = Date.now() + 5000;
while (!container.querySelector("textarea") && Date.now() < openDeadline) {
await new Promise((resolve) => requestAnimationFrame(resolve));
}
const transfer = new DataTransfer();
transfer.items.add(new File([new Uint8Array([137, 80, 78, 71])], "image.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: transfer });
-46
View File
@@ -186,49 +186,3 @@ test("quick theme menu offers all four palettes and applies their kind", async (
await expect(page.locator("html")).toHaveAttribute("data-theme", palette.kind);
}
});
test("gear › Theme previews the theme under the pointer and keeps the one chosen", async ({ workbench: page }) => {
const html = page.locator("html");
const gear = page.getByRole("button", { name: "IDE and Project Settings", exact: true });
const theme = (label: string) => page.getByRole("menuitemcheckbox", { name: label, exact: true });
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("Solarized Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
// A plugin's theme previews too.
await theme("Harbor Night").hover();
await expect(html).toHaveAttribute("data-plugin-theme", /harbor-night$/);
await expect(html).not.toHaveAttribute("data-color-theme");
await page.keyboard.press("Escape");
await page.keyboard.press("Escape");
await expect(html).not.toHaveAttribute("data-plugin-theme");
await expect(html).toHaveAttribute("data-theme", "light");
await gear.click();
await page.getByRole("menuitem", { name: "Theme", exact: true }).hover();
await theme("One Dark").hover();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await theme("One Dark").click();
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
// Chosen, not previewed: the next start-up opens with it.
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("openidea.theme") ?? "{}").colorTheme))
.toBe("one-dark");
});
test("Settings › Appearance › Theme previews the highlighted theme until Cancel", async ({ workbench: page }) => {
const html = page.locator("html");
const dialog = await settings(page);
await page.getByRole("button", { name: "Theme", exact: true }).click();
await page.getByRole("option", { name: "Solarized Dark", exact: true }).hover();
await expect(html).toHaveAttribute("data-color-theme", "solarized-dark");
await page.keyboard.press("Escape");
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
// Picked: it shows until Apply or Cancel.
await choose(page, "Theme", "One Dark");
await expect(html).toHaveAttribute("data-color-theme", "one-dark");
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
await expect(html).toHaveAttribute("data-theme", "light");
await expect(html).not.toHaveAttribute("data-color-theme");
});
-1
View File
@@ -2,7 +2,6 @@ import type { KnipConfig } from "knip";
const config: KnipConfig = {
// Entries come from the Vite (index.html), Vitest, Playwright and package.json script plugins.
entry: ["e2e/pythonHighlight.fixture.ts"],
project: [
"src/**/*.{ts,tsx,css}",
"!src/editor/core/cm/angular/parser.terms.ts",
+9 -15
View File
@@ -11,10 +11,9 @@ editors, and contribute menus, shortcuts, settings, themes, file icon themes
and languages. Plugins that only contribute data (themes, icons, languages,
menus, settings) need no code at all.
> **Preview.** The plugin system ships as a preview and the official plugins
> are versioned `0.x`. Released API versions only grow: a plugin built for
> `openidea:[email protected]` keeps loading in later 1.x IDEs. See _Evolving the
> plugin API_ below.
> **Preview.** The plugin system ships as a preview: until it is declared
> stable the API (`openidea:[email protected]`) may still change, and the official
> plugins are versioned `0.x`. See _Evolving the plugin API_ below.
```
plugins/
@@ -770,7 +769,7 @@ granted, and starts the approved program.
the package at its root or in its only folder, or a folder's `plugin.toml`;
a zip without a `plugin.toml` there, such as an IntelliJ plugin, is refused; IntelliJ
theme plugins are imported instead: _Import IntelliJ Theme from File…_ and _Import Theme
from JetBrains IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
from IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
page lists every capability and whether the package is signed by a publisher
the marketplace trusts; nothing is copied until you accept.
- **Versions:** installed plugins live in `<app-data>/plugins/<id>/<version>/`
@@ -920,8 +919,8 @@ the API itself follows _Evolving the plugin API_ below.
## 10. Evolving the plugin API
Released versions are frozen: 1.0.0 shipped with OpenIDEA 0.1.36 and 1.1.0
with 0.1.37. wasmtime decides what stays compatible: it resolves interface names
While the plugin system is a preview the API may still change freely. Once it
is declared stable wasmtime decides what stays compatible: it resolves interface names
by semver (a component importing `openidea:plugin/[email protected]` links against a
host defining `@1.1.0`, and the host finds its exports the same way), but
function types must match exactly — a record with another field count, or a
@@ -958,15 +957,10 @@ only grows:
Checked automatically:
- SDK `tests/api_frozen.rs`: `tests/api/released/` holds the WIT of the latest
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each
released version; every interface, type, function and world import or
export in it must still exist unchanged in `wit/` (rules 1–3). Under the
released version `wit/` must match it exactly, so additions start with a
version bump. Each snapshot passed this check against the one before, so
one snapshot covers every release.
- Releasing: when the `wit/` version changes, copy `wit/*.wit` over
`tests/api/released/` in the release commit. The release workflow refuses a
tag whose `wit/` version differs from the snapshot's.
export in it must still exist unchanged in `wit/` (rules 1–3). Copy `wit/`
there when releasing; there is no snapshot before the first release.
- `crates/ide-plugin/tests/api_compat.rs`: the versions agree, the SDK's copy
matches, the version check (rules 4, 5); a simulated next-minor host (every
import stubbed at the newer version, plus extra functions and an extra
+1 -1
View File
@@ -103,7 +103,7 @@ dependencies = [
[[package]]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.1"
version = "0.1.0"
dependencies = [
"openidea-plugin",
]
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "openidea-plugin-openidea-checkstyle"
version = "0.1.1"
version = "0.1.0"
edition = "2024"
publish = false
Binary file not shown.
+1 -1
View File
@@ -1 +1 @@
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
+1 -1
View File
@@ -1,6 +1,6 @@
id = "openidea.checkstyle"
name = "Checkstyle"
version = "0.1.1"
version = "0.1.0"
description = "Checks Java files with Checkstyle when you save them: problems in the editor and the Problems view, quick fixes for tabs, trailing spaces and missing final newlines."
authors = ["OpenIDEA contributors"]
license = "Apache-2.0"
+6 -62
View File
@@ -110,26 +110,6 @@ fn project_of(path: &str) -> Option<Project> {
.max_by_key(|p| p.root().map(|r| r.len()).unwrap_or(0))
}
fn windows_path(path: &str) -> String {
let path = path.replace('\\', "/");
if let Some(rest) = path.strip_prefix("//?/UNC/") {
format!("//{rest}")
} else {
path.strip_prefix("//?/").unwrap_or(&path).to_owned()
}
}
fn report_matches_file(reported: &str, path: &str) -> bool {
// WASI plugins run on every host, so interpret the host's path rather than cfg(windows).
let drive = path.as_bytes().get(1) == Some(&b':') && path.as_bytes()[0].is_ascii_alphabetic();
if drive || path.starts_with("//") || path.starts_with(r"\\") {
let (reported, path) = (windows_path(reported), windows_path(path));
reported == path || path.ends_with(&reported)
} else {
reported == path || path.ends_with(reported)
}
}
/// The configuration for files of `project`.
fn config_for(project: &Project) -> String {
PROJECT_CONFIGS
@@ -158,8 +138,11 @@ impl Checkstyle {
return Err(Error::Failed(format!("checkstyle: {}", out.stderr.lines().next().unwrap_or("no report"))));
}
let report = parse_report(&out.stdout);
let problems =
report.into_iter().find(|(f, _)| report_matches_file(f, path)).map(|(_, p)| p).unwrap_or_default();
let problems = report
.into_iter()
.find(|(f, _)| f == path || path.ends_with(f.as_str()))
.map(|(_, p)| p)
.unwrap_or_default();
let text = project.read_file(path).unwrap_or_default();
let diagnostics: Vec<Diagnostic> = problems.iter().map(|p| diagnostic(p, &text)).collect();
host::publish_diagnostics(path, &diagnostics)?;
@@ -250,7 +233,7 @@ impl Plugin for Checkstyle {
register_plugin!(Checkstyle, providers);
#[cfg(all(test, not(target_arch = "wasm32")))]
#[cfg(test)]
mod tests {
use super::*;
use openidea_plugin::ProcessOutput;
@@ -305,45 +288,6 @@ mod tests {
assert_eq!(actions[1].edits[0].1[0].text, " void f() {}");
}
#[test]
fn unix_report_paths_preserve_backslashes() {
assert!(report_matches_file("/project/A\\B.java", "/project/A\\B.java"));
assert!(!report_matches_file("/project/A\\B.java", "/project/A/B.java"));
}
#[test]
fn windows_reports_match_the_saved_file_and_keep_quick_fixes() {
for (root, reported) in [
("C:/project", r"C:\project\src\A.java"),
("C:/project", r"\\?\C:\project\src\A.java"),
("C:/project", r"src\A.java"),
("//server/share/project", r"\\server\share\project\src\A.java"),
("//server/share/project", r"\\?\UNC\server\share\project\src\A.java"),
] {
let host =
MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
host.set_project_root(Some(root));
host.add_file("src/A.java", "class A {\n\tint x;\n void f() {} \n}");
let xml = REPORT.replace("/project/src/A.java", reported);
host.on_process("checkstyle", move |_, _| {
Ok(ProcessOutput { status: Some(1), stdout: xml.clone(), stderr: String::new() })
});
let path = format!("{root}/src/A.java");
let mut plugin = Checkstyle::default();
plugin.handle_events(&[Event::FileSaved(path.clone())], &host.invocation());
assert_eq!(host.diagnostics()[&path].len(), 2, "{reported}");
let doc = Document {
path: path.clone(),
language: "java".into(),
version: 1,
text: "class A {\n\tint x;\n void f() {} \n}".into(),
};
let actions = plugin.code_actions(&doc, Range::new(Position::new(1, 0), Position::new(2, 0)));
assert_eq!(actions.len(), 2, "{reported}");
assert_eq!(actions[0].edits[0].0, path);
}
}
#[test]
fn the_projects_configuration_wins() {
let host = MockHost::install().with_capabilities(&["project.read", "events", "storage", "process:checkstyle"]);
@@ -22,13 +22,6 @@
"--syn-param": "#d9dfe8",
"--syn-property": "#b9a3f0",
"--syn-constant": "#e58fa3",
"--syn-python-local-variable": "#d9dfe8",
"--syn-python-global-variable": "#d9dfe8",
"--syn-python-instance-attribute": "#b9a3f0",
"--syn-python-class-attribute": "#b9a3f0",
"--syn-python-constant": "#e58fa3",
"--syn-python-property": "#b9a3f0",
"--syn-python-type-parameter": "#8fd4c9",
"--syn-builtin": "#6fc3df",
"--syn-self": "#d6849c",
"--syn-keyword-arg": "#c4a0f2",
@@ -67,10 +67,6 @@
"--diff-modified": "#2f4f63",
"--diff-inserted": "#294436",
"--diff-deleted": "#43494f",
"--diff-modified-stripe": "#3a6380",
"--diff-inserted-stripe": "#447152",
"--diff-deleted-stripe": "#5f676f",
"--diff-conflict-stripe": "#8f5247",
// Shadows and window controls
"--shadow-popup": "0 6px 20px rgba(0, 0, 0, 0.45)",
+5
View File
@@ -220,6 +220,11 @@ describe("IntelliJ theme import", () => {
],
});
expect(pkg.id).toBe("intellij.com-example-deepsea");
expect(pkg.themes).toEqual([
{ id: "deep-sea", sourceId: "deep-sea-id", name: "Deep Sea" },
{ id: "deep-sea-2", sourceId: "copy-id", name: "Deep Sea" },
{ id: "shallow", sourceId: "light-id", name: "Shallow" },
]);
expect(pkg.files.map((f) => f.path)).toEqual([
"plugin.toml",
"themes/deep-sea.theme.json",
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Workbench events, delivered in batches to `event-handler.handle`.
interface events {
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
interface lifecycle {
use types.{context, error};
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Writes to the IDE log, tagged with the plugin id.
interface log {
@@ -8,7 +8,7 @@ interface log {
}
interface ui {
use types.{error, notification-level};
use types.{notification-level};
record status-item {
text: string,
@@ -23,10 +23,6 @@ interface ui {
/// Shows or updates the plugin's status bar item `id` in every window.
set-status-item: func(id: string, item: status-item);
remove-status-item: func(id: string);
/// Copies `text` to the system clipboard. Requires `clipboard`.
copy-to-clipboard: func(text: string) -> result<_, error>;
/// Opens an `http://` or `https://` URL in the user's default browser.
open-url: func(url: string) -> result<_, error>;
}
/// Persistent key-value storage of the plugin (survives restarts and idle
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Problems in files, shown in the editor and the Problems view.
interface diagnostics {
@@ -7,7 +7,7 @@
/// or processes beyond WASI's clocks and randomness; everything else goes
/// through the imports below, gated by the capabilities granted in
/// `plugin.toml` (a missing one returns `error::denied`).
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
world plugin {
import log;
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Editor features a plugin provides for the languages it declares under
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
/// as in the editor and LSP.
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
/// Declarative views: tool windows declared
/// under `[[contributes.views]]` and custom editors under
@@ -133,10 +133,6 @@ interface views {
/// `program` (pinned by a `process:<program>` grant) with `args` in the
/// project root: long-running output (logs) and interactive sessions.
open-terminal: func(title: string, program: string, args: list<string>) -> result<_, error>;
/// Opens `path` (project-relative or absolute, inside the project) in an
/// editor of the window the call came from, the caret at `line` and
/// `column` (0-based; UTF-16 column): a view's links to code.
open-location: func(path: string, line: u32, column: u32) -> result<_, error>;
}
/// The plugin's own settings (`[contributes.settings]`), as the user set them
@@ -1,4 +1,4 @@
package openidea:plugin@1.1.0;
package openidea:plugin@1.0.0;
interface workspace {
use types.{error};
@@ -84,8 +84,6 @@ interface vcs {
/// `none` when the project is not in a repository. Requires `vcs.read`.
status: func(project: borrow<project>) -> result<option<repository-status>, error>;
/// Configured remotes (`name`, fetch `url`), in order. Requires `vcs.read`.
remotes: func(project: borrow<project>) -> result<list<tuple<string, string>>, error>;
/// Fetches all remotes (pruning deleted branches). Requires `vcs.write`.
/// Long-running: call it inside a task.
fetch: func(project: borrow<project>) -> result<_, error>;
+61 -69
View File
@@ -3,13 +3,11 @@
//! a field added to a record or a case added to a variant breaks every
//! component built for the release.
//!
//! `tests/api/released/` is the WIT of the latest released version. Every
//! interface, type and function in it must still exist in `wit/` with the same
//! definition. Additions need a newer version than the snapshot's: under the
//! released version `wit/` must match it exactly. When a version ships, copy
//! `wit/` over the snapshot (the release workflow refuses a tag whose `wit/`
//! version differs from it). Each snapshot passed this check against the one
//! before, so the latest one covers every earlier release.
//! `tests/api/<version>/` is the WIT of each release, copied when it was
//! frozen. Every interface, type and function in it must still exist in
//! `wit/` with the same definition; additions are fine. When a version is
//! released, copy `wit/` to `tests/api/<that version>/`. Until the first
//! release there is no snapshot and the API may still change freely.
use std::collections::BTreeMap;
use std::path::Path;
@@ -112,83 +110,77 @@ fn anonymous(r: &Resolve, id: TypeId) -> String {
}
}
fn root() -> &'static Path {
Path::new(env!("CARGO_MANIFEST_DIR"))
}
/// The latest released version's WIT.
fn released() -> std::path::PathBuf {
root().join("tests/api/released")
}
/// The `package openidea:[email protected]` version of a WIT directory.
fn version(dir: &Path) -> Vec<u64> {
let src = std::fs::read_to_string(dir.join("plugin.wit")).unwrap_or_else(|e| panic!("{}: {e}", dir.display()));
let v = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
v.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect()
/// The frozen releases (`tests/api/<version>/`), none before the first release.
fn releases(root: &Path) -> Vec<std::path::PathBuf> {
match std::fs::read_dir(root.join("tests/api")) {
Ok(dir) => dir.map(|e| e.unwrap().path()).filter(|p| p.is_dir()).collect(),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => panic!("tests/api: {e}"),
}
}
#[test]
fn the_surface_covers_the_whole_api() {
for dir in [root().join("wit"), released()] {
let items = surface(&dir);
assert!(items.len() > 50, "{}: only {} items: did it load?", dir.display(), items.len());
assert!(items.keys().any(|k| k.starts_with("world ")));
assert!(items.keys().any(|k| k.contains("/func ")));
assert!(items.keys().any(|k| k.contains("/type ")));
}
// Keeps the check below meaningful while there is no release to compare.
let current = surface(&Path::new(env!("CARGO_MANIFEST_DIR")).join("wit"));
assert!(current.len() > 50, "only {} items: did wit/ load?", current.len());
assert!(current.keys().any(|k| k.starts_with("world ")));
assert!(current.keys().any(|k| k.contains("/func ")));
assert!(current.keys().any(|k| k.contains("/type ")));
}
#[test]
fn the_released_api_is_unchanged() {
let current = surface(&root().join("wit"));
let release = surface(&released());
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was. Items are
// compared without their `@version`: a later minor names them `@1.2.0`, and wasmtime
// resolves the release's `@1.1.0` to it.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end]
.split(", ")
.filter(|x| !x.is_empty())
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
.collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
fn released_apis_are_unchanged() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let current = surface(&root.join("wit"));
for dir in releases(root) {
let release = surface(&dir);
let version = dir.file_name().unwrap().to_string_lossy().into_owned();
let mut broken = Vec::new();
for (key, was) in &release {
match current.get(key) {
None => broken.push(format!("{key}: removed (was {was})")),
// A world may gain imports and exports; everything else stays as it was. Items are
// compared without their `@version`: a later minor names them `@1.1.0`, and wasmtime
// resolves the release's `@1.0.0` to it.
Some(now) if key.starts_with("world ") => {
let had = |s: &str, part: &str| -> Vec<String> {
let start = s.find(part).unwrap() + part.len();
let end = s[start..].find(']').unwrap() + start;
s[start..end]
.split(", ")
.filter(|x| !x.is_empty())
.map(|x| x.split('@').next().unwrap_or(x).to_owned())
.collect()
};
for part in ["imports [", "exports ["] {
let now = had(now, part);
for item in had(was, part) {
if !now.contains(&item) {
broken.push(format!("{key}: no longer has {item}"));
}
}
}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
Some(now) if now != was => broken.push(format!("{key}:\n was {was}\n now {now}")),
Some(_) => {}
}
assert!(broken.is_empty(), "plugin API {version} changed incompatibly:\n {}", broken.join("\n "));
assert!(release.len() > 50, "{version}: only {} items: did the snapshot load?", release.len());
}
assert!(broken.is_empty(), "the released plugin API changed incompatibly:\n {}", broken.join("\n "));
}
#[test]
fn additions_need_a_newer_version() {
// A component built from `wit/` imports what `wit/` declares; under a released version an
// IDE of that release would lack the additions and refuse it.
let (current, release) = (version(&root().join("wit")), version(&released()));
assert!(current >= release, "wit/ ({current:?}) is older than the released API ({release:?})");
if current == release {
let (now, was) = (surface(&root().join("wit")), surface(&released()));
let added: Vec<_> = now.keys().filter(|k| now.get(*k) != was.get(*k)).collect();
assert!(
added.is_empty(),
"wit/ adds to the released version {release:?}; bump the package version in wit/ \
(and the SDK's copy, ENGINE_VERSION, API_VERSION) first:\n {added:?}"
);
fn the_current_api_is_a_release_or_newer_than_all() {
// `wit/` is either a frozen release or the next one; never older.
let root = Path::new(env!("CARGO_MANIFEST_DIR"));
let src = std::fs::read_to_string(root.join("wit/plugin.wit")).unwrap();
let current = src.lines().find_map(|l| l.trim().strip_prefix("package openidea:plugin@")).unwrap();
let current: Vec<u64> = current.trim_end_matches(';').split('.').map(|p| p.parse().unwrap()).collect();
for dir in releases(root) {
let name = dir.file_name().unwrap().to_string_lossy().into_owned();
let release: Vec<u64> = name.split('.').map(|p| p.parse().unwrap()).collect();
assert!(release <= current, "tests/api/{name} is newer than wit/ ({current:?})");
}
}
-10
View File
@@ -93,16 +93,6 @@ objc2-web-kit = { version = "0.3", default-features = false, features = ["std",
gtk = "0.18"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Storage_EnhancedStorage",
"Win32_System_Com",
"Win32_System_Com_StructuredStorage",
"Win32_System_Variant",
"Win32_UI_Shell",
"Win32_UI_Shell_Common",
"Win32_UI_Shell_PropertiesSystem",
] }
windows-sys = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Gdi",
+5 -6
View File
@@ -46,9 +46,7 @@ pub fn recent_projects(state: State<'_, AppState>) -> Vec<RecentProject> {
#[tauri::command]
#[specta::specta]
pub fn remove_recent_project(state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
state.recent.remove(&path)?;
crate::jump_list::refresh(&state.app, state.recent.list());
Ok(())
state.recent.remove(&path)
}
/// The welcome screen's project groups.
@@ -85,8 +83,10 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
let info = project_info(&project);
let _ = window.set_title(&info.name);
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
// Every project window is part of the session reopened at the next start.
crate::window_groups::persist(window.app_handle()).await?;
let grouped = window.state::<crate::windows::WindowsState>().groups.lock().group(window.label()).is_some();
if grouped {
crate::window_groups::persist(window.app_handle()).await?;
}
Ok(info)
}
@@ -125,7 +125,6 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String)
let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) };
project.settings.set(SettingsScope::Project, "general.projectName", value)?;
state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?;
crate::jump_list::refresh(&state.app, state.recent.list());
Ok(project_info(&project))
}
+1
View File
@@ -262,6 +262,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
vcs::vcs_open_external_log,
vcs::vcs_log_details,
vcs::vcs_log_details_batch,
vcs::vcs_log_index,
vcs::vcs_revision_children,
vcs::vcs_revision_files,
vcs::vcs_commit_files,
+10
View File
@@ -142,6 +142,16 @@ pub async fn vcs_log_details_batch(
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_log_index(
window: Window,
state: State<'_, AppState>,
repo: Option<PathBuf>,
request: Option<RequestId>,
) -> IdeResult<LogIndexProgress> {
ide_host::ops::vcs::vcs_log_index(&state.host, window.label(), repo, request).await
}
#[tauri::command]
#[specta::specta]
pub async fn vcs_revision_children(
window: Window,
state: State<'_, AppState>,
-170
View File
@@ -1,170 +0,0 @@
//! Windows taskbar Jump List: the recent projects of the running window.
//!
//! The list is built while OpenIDEA runs, so its taskbar button offers the same
//! recent projects as Welcome / File › Open Recent. `set_app_id` must run before
//! the first window is created so the taskbar groups every launch under one
//! AppUserModelID.
use ide_workspace::RecentProject;
/// Windows shows only a handful of custom destinations; keep the taskbar menu short.
#[cfg(any(windows, test))]
const MAX_ITEMS: usize = 10;
#[cfg(windows)]
const CATEGORY: &str = "Recent Projects";
/// Marks a second-instance launch from a Jump List item, so it opens in the existing window.
pub const OPEN_RECENT_ARG: &str = "--open-recent";
pub fn set_app_id(app_id: &str) {
#[cfg(windows)]
imp::set_app_id(app_id);
#[cfg(not(windows))]
let _ = app_id;
}
/// Rebuilds the taskbar Jump List in the background; `items` is newest first.
pub fn refresh(app: &tauri::AppHandle, items: Vec<RecentProject>) {
#[cfg(windows)]
imp::refresh(app, items);
#[cfg(not(windows))]
let _ = (app, items);
}
/// The entries a Jump List shows: newest first, capped at [`MAX_ITEMS`].
#[cfg(any(windows, test))]
pub(crate) fn visible_items(items: &[RecentProject]) -> Vec<(&str, &std::path::Path)> {
items.iter().take(MAX_ITEMS).map(|item| (item.name.as_str(), item.path.0.as_path())).collect()
}
#[cfg(windows)]
mod imp {
use super::*;
use std::sync::Mutex;
use windows::Win32::Storage::EnhancedStorage::{PKEY_AppUserModel_ID, PKEY_Title};
use windows::Win32::System::Com::StructuredStorage::{PROPVARIANT, PropVariantClear};
use windows::Win32::System::Com::{
CLSCTX_INPROC_SERVER, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
};
use windows::Win32::UI::Shell::Common::{IObjectArray, IObjectCollection};
use windows::Win32::UI::Shell::PropertiesSystem::IPropertyStore;
use windows::Win32::UI::Shell::{
DestinationList, EnumerableObjectCollection, ICustomDestinationList, IShellLinkW,
SetCurrentProcessExplicitAppUserModelID, ShellLink,
};
use windows::core::{HSTRING, Interface};
/// Shell COM is not thread-safe here; serialize updates from simultaneous project opens.
static UPDATE_LOCK: Mutex<()> = Mutex::new(());
pub fn set_app_id(app_id: &str) {
if let Err(error) = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(app_id)) } {
tracing::warn!(%error, "cannot set the taskbar AppUserModelID");
}
}
pub fn refresh(app: &tauri::AppHandle, items: Vec<RecentProject>) {
let app_id = app.config().identifier.clone();
let worker = std::thread::Builder::new().name("openidea-jump-list".into()).spawn(move || {
let _guard = UPDATE_LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
let com = ComGuard::new();
if let Err(error) = update(&app_id, &items) {
tracing::warn!(%error, "cannot update the taskbar Jump List");
}
drop(com);
});
if let Err(error) = worker {
tracing::warn!(%error, "cannot start the taskbar Jump List worker");
}
}
struct ComGuard {
uninitialize: bool,
}
impl ComGuard {
fn new() -> Self {
let result = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
let uninitialize = result.0 >= 0;
if !uninitialize {
tracing::debug!(%result, "COM already initialized for the taskbar Jump List");
}
Self { uninitialize }
}
}
impl Drop for ComGuard {
fn drop(&mut self) {
if self.uninitialize {
unsafe { CoUninitialize() };
}
}
}
fn update(app_id: &str, items: &[RecentProject]) -> windows::core::Result<()> {
let app_id_hstring = HSTRING::from(app_id);
let visible = visible_items(items);
unsafe {
let list: ICustomDestinationList = CoCreateInstance(&DestinationList, None, CLSCTX_INPROC_SERVER)?;
list.SetAppID(&app_id_hstring)?;
if visible.is_empty() {
let _ = list.DeleteList(&app_id_hstring);
return Ok(());
}
let mut min_slots = 0u32;
let _removed: IObjectArray = list.BeginList(&mut min_slots)?;
let collection: IObjectCollection =
CoCreateInstance(&EnumerableObjectCollection, None, CLSCTX_INPROC_SERVER)?;
let exe = HSTRING::from(std::env::current_exe()?.to_string_lossy().as_ref());
for (name, path) in visible {
let link: IShellLinkW = CoCreateInstance(&ShellLink, None, CLSCTX_INPROC_SERVER)?;
link.SetPath(&exe)?;
let mut arguments = String::with_capacity(path.as_os_str().len() + OPEN_RECENT_ARG.len() + 3);
arguments.push_str(OPEN_RECENT_ARG);
arguments.push(' ');
arguments.push('"');
arguments.push_str(&path.to_string_lossy());
arguments.push('"');
link.SetArguments(&HSTRING::from(arguments.as_str()))?;
link.SetDescription(&HSTRING::from(path.to_string_lossy().as_ref()))?;
if let Some(parent) = path.parent() {
link.SetWorkingDirectory(&HSTRING::from(parent.to_string_lossy().as_ref()))?;
}
link.SetIconLocation(&exe, 0)?;
let properties: IPropertyStore = link.cast()?;
let mut title = PROPVARIANT::from(name);
let mut app_id_value = PROPVARIANT::from(app_id);
let title_result = properties.SetValue(&PKEY_Title, &title);
let app_id_result = properties.SetValue(&PKEY_AppUserModel_ID, &app_id_value);
let _ = PropVariantClear(&mut title);
let _ = PropVariantClear(&mut app_id_value);
title_result?;
app_id_result?;
properties.Commit()?;
collection.AddObject(&link)?;
}
let destinations: IObjectArray = collection.cast()?;
list.AppendCategory(&HSTRING::from(CATEGORY), &destinations)?;
list.CommitList()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ide_core::IpcPath;
fn item(name: &str, path: &str) -> RecentProject {
RecentProject { path: IpcPath::from(path), name: name.into(), opened_at_ms: 0.0, branch: None }
}
#[test]
fn shows_the_newest_items_capped() {
let items: Vec<_> = (0..15).map(|i| item(&format!("p{i}"), &format!("/p/{i}"))).collect();
let visible = visible_items(&items);
assert_eq!(visible.len(), 10);
assert_eq!(visible[0].0, "p0");
assert_eq!(visible[9].0, "p9");
}
}
-4
View File
@@ -8,7 +8,6 @@ mod commands;
#[cfg(debug_assertions)]
mod dev;
mod events;
mod jump_list;
#[cfg(any(target_os = "linux", test))]
mod linux_window_state;
mod logging;
@@ -65,8 +64,6 @@ pub fn run() {
let context = tauri::generate_context!();
#[cfg(debug_assertions)]
let context = dev::configure(context);
// Before the first window: one taskbar identity for every launch path.
jump_list::set_app_id(&context.config().identifier);
logging::init(&context.config().identifier);
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier);
@@ -177,7 +174,6 @@ pub fn run() {
}
app.on_menu_event(on_menu_event);
windows::create_main_window(app.handle())?;
jump_list::refresh(app.handle(), app.state::<AppState>().recent.list());
Ok(())
})
.on_window_event(|window, event| match event {
-1
View File
@@ -145,7 +145,6 @@ impl AppState {
if !display_name.trim().is_empty() {
self.recent.rename(&project.root, Some(display_name.trim()))?;
}
crate::jump_list::refresh(&self.app, self.recent.list());
Ok(project)
}
+46 -64
View File
@@ -433,36 +433,44 @@ fn close_next(app: &AppHandle) {
}
}
/// Window labels in creation order: `main`, then `project-1`, `project-2`, ….
fn labels_in_order(app: &AppHandle) -> Vec<String> {
let mut labels: Vec<String> = app.webview_windows().into_keys().collect();
labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::<u32>().ok()), l.clone()));
labels
#[derive(Serialize, Deserialize)]
struct SavedGroup {
projects: Vec<PathBuf>,
selected: usize,
}
/// Saves the open project windows (groups and single windows) for "Reopen projects on startup".
/// Nothing is written while quitting, so the windows open at quit are the ones reopened.
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
let state = app.state::<WindowsState>();
let mut previous = state.groups_persist.lock().await;
let _write = state.groups_persist.lock().await;
if state.quitting() {
return Ok(());
}
let host = app.state::<AppState>();
let path = host.config_dir.join("window-groups.json");
if previous.is_none() {
*previous = Some(read_session(path.clone()).await);
}
let groups = state.groups.lock().groups().to_vec();
let windows = labels_in_order(app);
let project_of = |label: &str| match host.project(label) {
Ok(p) => (!p.light).then(|| p.root.clone()),
// A restored window may have taken its launch but not finished opening yet.
Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into),
};
let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else {
let host = app.state::<AppState>();
let saved: Vec<_> = groups
.iter()
.filter_map(|group| {
let projects: Vec<_> = group
.members
.iter()
.filter_map(|label| {
host.project(label)
.ok()
.map(|p| p.root.clone())
.or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into))
})
.collect();
(projects.len() == group.members.len()).then(|| SavedGroup {
projects,
selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0),
})
})
.collect();
// A restored member may have taken its launch but not finished opening yet.
if saved.len() != groups.len() {
return Ok(());
};
}
let path = host.config_dir.join("window-groups.json");
tauri::async_runtime::spawn_blocking(move || {
let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?;
ide_vfs::write_atomic(&path, &bytes).map(|_| ())
@@ -471,22 +479,6 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> {
.map_err(|e| IdeError::internal(e.to_string()))?
}
/// The saved session; empty when there is none or it can't be read.
async fn read_session(path: PathBuf) -> Vec<ide_workspace::window_groups::SavedWindows> {
let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(format!("{}: {e}", path.display())),
})
.await
.map_err(|e| e.to_string())
.and_then(|r| r);
read.unwrap_or_else(|e| {
tracing::warn!("cannot read the saved windows: {e}");
Vec::new()
})
}
pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
if caller != "main" {
return Ok(());
@@ -497,51 +489,41 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> {
if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) {
return Ok(());
}
let saved = {
let mut previous = state.groups_persist.lock().await;
if previous.is_none() {
*previous = Some(read_session(host.config_dir.join("window-groups.json")).await);
let path = host.config_dir.join("window-groups.json");
let saved: Vec<SavedGroup> = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> {
match std::fs::read(path) {
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
Err(e) => Err(e.into()),
}
previous.clone().unwrap_or_default()
};
// The caller's own group first (it already shows one of its projects), then every other
// group and single window that was open at quit. Missing folders are skipped.
let order = saved
.iter()
.filter(|g| g.projects.contains(&project.root))
.chain(saved.iter().filter(|g| !g.projects.contains(&project.root)));
for group in order {
})
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
let mut labels = Vec::new();
let mut selected = None;
let mut selected = caller.to_owned();
for path in &group.projects {
let label = if path == &project.root {
Some(caller.to_owned())
} else if path.is_dir() {
let window = match windows::find_project_window(app, path) {
Some(w) => Ok(w),
None => windows::create_restored_window(app, path.clone()),
Some(w) => w,
None => windows::create_restored_window(app, path.clone())?,
};
match window {
Ok(w) => Some(w.label().to_owned()),
Err(e) => {
tracing::warn!("cannot reopen {}: {e}", path.display());
None
}
}
Some(window.label().to_owned())
} else {
None
};
if let Some(label) = label {
if group.projects.get(group.selected) == Some(path) {
selected = Some(label.clone());
selected = label.clone();
}
labels.push(label);
}
}
if labels.len() > 1 {
let selected = selected.unwrap_or_else(|| labels[0].clone());
state.groups.lock().merge(labels, &selected)?;
select(app, &selected, &selected)?;
state.groups.lock().merge(labels, caller)?;
select(app, caller, &selected)?;
}
}
Ok(())
+2 -32
View File
@@ -120,9 +120,7 @@ pub fn wsl_launch(path: &Path) -> Option<RemoteLaunch> {
pub struct WindowsState {
pub groups: Mutex<ide_workspace::window_groups::WindowGroups>,
pub groups_restored: AtomicBool,
/// Serialises writes of the saved session. Holds the previous run's session once read: it is
/// read before this run first overwrites the file, so restoring still sees it.
pub groups_persist: tokio::sync::Mutex<Option<Vec<ide_workspace::window_groups::SavedWindows>>>,
pub groups_persist: tokio::sync::Mutex<()>,
launches: Mutex<HashMap<String, Launch>>,
/// Caret positions asked for on the command line, by canonical file path, until a window takes the file.
positions: Mutex<HashMap<PathBuf, (u32, u32)>>,
@@ -925,17 +923,8 @@ pub fn open_path(app: &AppHandle, path: &Path) {
/// Opens paths from outside the app (a second instance, Finder's Open With, the Dock):
/// see [`plan_open`]; the focused window is the preferred free one.
pub fn open_paths(app: &AppHandle, paths: Vec<PathBuf>) {
let (existing, missing): (Vec<_>, Vec<_>) = paths.into_iter().partition(|path| path.exists());
if !missing.is_empty() {
let paths = missing.iter().map(|path| path.display().to_string()).collect::<Vec<_>>().join("\n");
app.state::<AppState>().sink.emit(ide_core::IdeEvent::Notify {
level: ide_core::event::NotifyLevel::Warning,
title: "Cannot open path".into(),
body: Some(format!("The path does not exist:\n{paths}")),
});
}
let preferred = target_window(app).map(|w| w.label().to_owned());
run_open(app, plan_open(resolve(existing), &window_holds(app, preferred.as_deref())));
run_open(app, plan_open(resolve(paths), &window_holds(app, preferred.as_deref())));
}
/// What each window holds, `preferred` first.
@@ -1016,25 +1005,6 @@ fn launch_for(path: PathBuf, is_dir: bool) -> Launch {
/// Second instance launched: focus this one and open its path argument.
pub fn on_second_instance(app: &AppHandle, args: Vec<String>, cwd: String) {
if args.iter().any(|arg| arg == crate::jump_list::OPEN_RECENT_ARG)
&& let Some(path) = cli_path(&args, Path::new(&cwd)).filter(|path| path.exists())
{
let target = target_window(app)
.filter(|window| auxiliary_parent(window.label()).is_none())
.or_else(|| project_window_labels(app).into_iter().find_map(|label| app.get_webview_window(&label)));
if let Some(window) = target {
let _ = app.emit_to(
window.label(),
crate::menu::MENU_EVENT,
crate::menu::MenuRoute {
action: crate::menu::OPEN_RECENT_ACTION.into(),
arg: Some(ide_core::ipc_path::to_ipc(&path)),
},
);
focus(&window);
return;
}
}
match cli_path(&args, Path::new(&cwd)) {
Some(path) => {
remember_cli_position(&app.state::<WindowsState>(), &args, &path);
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff. Show more