mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58afe0b154 | ||
|
|
b70c01d1d6 | ||
|
|
653ebd578a | ||
|
|
6e6baf4284 | ||
|
|
de09ee0678 | ||
|
|
292dc3cade | ||
|
|
d4a5b54720 | ||
|
|
c495c09416 | ||
|
|
324dded5c1 | ||
|
|
36fd117b29 | ||
|
|
9fb38769ad | ||
|
|
087cb4cbaa | ||
|
|
f6a1823121 | ||
|
|
4959b430b1 | ||
|
|
915fec598f | ||
|
|
94576eb879 | ||
|
|
8c915664e9 | ||
|
|
253fcfb7e0 | ||
|
|
24cf1a52d5 | ||
|
|
7071fd9107 | ||
|
|
df27de217e | ||
|
|
bff5d11346 | ||
|
|
9a48426740 | ||
|
|
dadec04d62 | ||
|
|
9bd42381b2 | ||
|
|
47e5b0f59c | ||
|
|
4a217a4473 | ||
|
|
1aec21b43f | ||
|
|
d3d0fd23c6 | ||
|
|
c3b7e34b22 | ||
|
|
91d6c7de57 | ||
|
|
abb7b1d962 | ||
|
|
c57156ef4b | ||
|
|
f55cdcdc31 | ||
|
|
7d267ab50e | ||
|
|
50986088f9 | ||
|
|
7a0f60377c | ||
|
|
945f09347e | ||
|
|
2924824e7b | ||
|
|
26e3ecc3a0 | ||
|
|
456b329d73 |
No files matched your search
@@ -9,7 +9,8 @@
|
||||
|
||||
<!-- Required. Commands you actually ran and their results; what else this could
|
||||
affect and how you checked it still works.
|
||||
UI changes: before/after screenshots. Multi-step interactions: a recording.
|
||||
Visible UI changes: before/after screenshots (not for removed UI or copy-only
|
||||
changes). Multi-step interactions: a recording.
|
||||
Backend changes: logs or command output. -->
|
||||
|
||||
## Review focus
|
||||
|
||||
@@ -7,6 +7,9 @@ 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.
|
||||
@@ -19,8 +22,50 @@ 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:
|
||||
@@ -62,6 +107,9 @@ 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
|
||||
@@ -77,6 +125,9 @@ 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
|
||||
@@ -126,6 +177,9 @@ 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
|
||||
@@ -135,6 +189,9 @@ 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
|
||||
@@ -152,6 +209,9 @@ 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
|
||||
|
||||
@@ -11,10 +11,7 @@ name: Language smoke
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
paths: &paths
|
||||
- "crates/**"
|
||||
- "languages/**"
|
||||
- "fixtures/**"
|
||||
@@ -27,6 +24,10 @@ on:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- ".github/workflows/language-smoke.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened]
|
||||
paths: *paths
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -37,6 +37,16 @@ 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
|
||||
|
||||
@@ -51,7 +51,8 @@ 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 (for UI) before/after screenshots.
|
||||
**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).
|
||||
Unclear product decisions → `needs-decision` and stop.
|
||||
|
||||
## Skills
|
||||
|
||||
+3
-2
@@ -25,8 +25,9 @@ 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. UI changes need before/after screenshots;
|
||||
multi-step interactions need a recording.
|
||||
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.
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
|
||||
Generated
+1
@@ -6379,6 +6379,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"windows",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
·
|
||||
<a href="#plugins">Plugins</a>
|
||||
·
|
||||
<a href="https://discord.gg/2Zy4yn7gP">Discord</a>
|
||||
<a href="#community">Community</a>
|
||||
</p>
|
||||
|
||||
https://github.com/user-attachments/assets/234dd54c-1255-4b3a-b35c-533d78d737c0
|
||||
@@ -57,6 +57,13 @@ 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).
|
||||
|
||||
@@ -440,7 +440,9 @@ 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> {
|
||||
let dir = root.join(dir);
|
||||
// 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();
|
||||
if !SETTINGS_FILES.iter().chain(BUILD_FILES).any(|name| dir.join(name).is_file()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -722,6 +722,10 @@ 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)]
|
||||
|
||||
@@ -30,6 +30,21 @@ 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);
|
||||
|
||||
@@ -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) and paths recorded on another
|
||||
//! content (lcov, Go cover profile, JaCoCo XML, SimpleCov resultset) and paths recorded on another
|
||||
//! machine are mapped into the project by their longest existing suffix.
|
||||
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov};
|
||||
use crate::{Accumulator, CoverageSuite, goprofile, jacoco, lcov, simplecov};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
@@ -11,6 +11,7 @@ pub(crate) enum Format {
|
||||
Lcov,
|
||||
GoProfile,
|
||||
Jacoco,
|
||||
SimpleCov,
|
||||
}
|
||||
|
||||
pub(crate) fn detect(text: &str) -> Option<Format> {
|
||||
@@ -18,6 +19,9 @@ 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);
|
||||
@@ -30,6 +34,7 @@ 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")
|
||||
@@ -50,7 +55,7 @@ pub fn import(path: &Path, root: &Path) -> IdeResult<CoverageSuite> {
|
||||
}
|
||||
None => {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"{} is not an lcov tracefile, Go cover profile or JaCoCo XML report",
|
||||
"{} is not an lcov tracefile, Go cover profile, JaCoCo XML report or SimpleCov resultset",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
@@ -125,9 +130,29 @@ 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();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
//! | 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 |
|
||||
//!
|
||||
@@ -20,6 +21,7 @@ mod import;
|
||||
mod jacoco;
|
||||
mod lcov;
|
||||
mod plan;
|
||||
mod simplecov;
|
||||
|
||||
pub use import::import;
|
||||
pub use plan::prepare;
|
||||
@@ -83,6 +85,8 @@ 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>`).
|
||||
@@ -185,6 +189,10 @@ 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"))
|
||||
|
||||
@@ -67,6 +67,7 @@ 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.",
|
||||
@@ -234,6 +235,65 @@ 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.");
|
||||
@@ -398,6 +458,74 @@ 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();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//! 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());
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ mod java_runtime;
|
||||
mod kotlin_bridge;
|
||||
pub mod notebook;
|
||||
pub mod ops;
|
||||
mod phpstan;
|
||||
pub mod plugins;
|
||||
mod project;
|
||||
mod proxy;
|
||||
@@ -86,6 +87,8 @@ pub struct Host {
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
/// Shutdowns of projects closed with [`Host::close_project_detached`].
|
||||
closing: parking_lot::Mutex<Vec<std::thread::JoinHandle<()>>>,
|
||||
/// One open at a time per window (see [`Host::opening`]).
|
||||
open_locks: parking_lot::Mutex<HashMap<String, Arc<parking_lot::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
/// How long exit waits for project shutdown before giving up.
|
||||
@@ -135,6 +138,7 @@ impl Host {
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
closing: Default::default(),
|
||||
open_locks: Default::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -225,6 +229,9 @@ 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();
|
||||
@@ -346,6 +353,9 @@ 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();
|
||||
@@ -401,6 +411,13 @@ impl Host {
|
||||
self.requests.cancel(id);
|
||||
}
|
||||
|
||||
/// The lock an open of `window` holds from its check for the current project to its
|
||||
/// insert: two opens racing (a double click, a launch path and a restore) would each start
|
||||
/// an index, language servers and watchers, and the second would replace the first unclosed.
|
||||
pub(crate) fn opening(&self, window: &str) -> Arc<parking_lot::Mutex<()>> {
|
||||
self.open_locks.lock().entry(window.to_owned()).or_default().clone()
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
|
||||
@@ -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, SharedSink};
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, IpcPath, 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: String,
|
||||
pub path: IpcPath,
|
||||
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.to_string_lossy().into_owned(),
|
||||
path: document.path.clone().into(),
|
||||
cell: cell.to_owned(),
|
||||
msg_id: output.msg_id,
|
||||
kind: output.kind,
|
||||
@@ -592,6 +592,16 @@ 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();
|
||||
|
||||
@@ -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 or JaCoCo XML,
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile, JaCoCo XML or a SimpleCov resultset,
|
||||
/// 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();
|
||||
|
||||
@@ -198,6 +198,7 @@ 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();
|
||||
@@ -214,6 +215,21 @@ 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) {
|
||||
|
||||
@@ -24,7 +24,11 @@ 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));
|
||||
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
|
||||
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))
|
||||
}
|
||||
|
||||
/// The language definitions plus the servers defined in the window's settings.
|
||||
|
||||
@@ -405,28 +405,6 @@ 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,
|
||||
@@ -1222,10 +1200,6 @@ 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
|
||||
});
|
||||
|
||||
@@ -0,0 +1,850 @@
|
||||
//! 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,6 +2,7 @@
|
||||
//! 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;
|
||||
@@ -419,7 +420,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: repo.root().to_string_lossy().into_owned(),
|
||||
root: to_ipc(repo.root()),
|
||||
branch: info.head,
|
||||
commit: info.head_commit,
|
||||
upstream: info.upstream,
|
||||
@@ -467,7 +468,7 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
let Ok(rel) = entry.path().strip_prefix(root) else { continue };
|
||||
if matcher.is_match(rel) {
|
||||
out.push(entry.path().to_string_lossy().into_owned());
|
||||
out.push(to_ipc(entry.path()));
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
@@ -806,6 +807,19 @@ 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();
|
||||
|
||||
@@ -422,7 +422,8 @@ struct IdeDir {
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
/// 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).
|
||||
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 {
|
||||
@@ -462,6 +463,8 @@ 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
|
||||
}
|
||||
|
||||
@@ -784,6 +787,13 @@ 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");
|
||||
|
||||
@@ -55,6 +55,7 @@ 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)>>,
|
||||
@@ -77,6 +78,8 @@ impl Host {
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
// Reject a damaged manifest before replacing the currently open project.
|
||||
ide_workspace::workspace::Workspace::load(&root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
@@ -290,12 +293,21 @@ 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();
|
||||
@@ -319,6 +331,7 @@ 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)) {
|
||||
@@ -438,6 +451,7 @@ impl Host {
|
||||
&history,
|
||||
&lsp,
|
||||
Some(&kotlin_bridge),
|
||||
Some(&phpstan),
|
||||
)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
@@ -484,6 +498,7 @@ 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()),
|
||||
@@ -514,6 +529,9 @@ 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());
|
||||
}
|
||||
@@ -569,6 +587,8 @@ impl Host {
|
||||
return Err(IdeError::invalid(format!("{} is a folder", file.display())));
|
||||
}
|
||||
let dir = file.parent().map(Path::to_path_buf).ok_or_else(|| IdeError::invalid("no parent folder"))?;
|
||||
let opening = self.opening(window);
|
||||
let _opening = opening.lock();
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.light
|
||||
{
|
||||
@@ -622,6 +642,7 @@ impl Host {
|
||||
agents,
|
||||
notebooks,
|
||||
kotlin_bridge: None,
|
||||
phpstan: None,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(Vec::new()),
|
||||
lsp_watchers: Mutex::new(Vec::new()),
|
||||
@@ -998,6 +1019,7 @@ impl Project {
|
||||
&self.history,
|
||||
&self.lsp,
|
||||
self.kotlin_bridge.as_ref(),
|
||||
self.phpstan.as_ref(),
|
||||
) {
|
||||
watchers.push((r, w));
|
||||
}
|
||||
@@ -1133,8 +1155,9 @@ 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 = kotlin_bridge.cloned();
|
||||
let (kotlin_bridge, phpstan) = (kotlin_bridge.cloned(), phpstan.cloned());
|
||||
let (index, documents, history, lsp) = (index.clone(), documents.clone(), history.clone(), lsp.clone());
|
||||
let root = dir.to_path_buf();
|
||||
Watcher::start(
|
||||
@@ -1152,6 +1175,9 @@ 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);
|
||||
}),
|
||||
|
||||
@@ -109,6 +109,9 @@ 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);
|
||||
}
|
||||
@@ -316,7 +319,6 @@ 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"
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
//! Opening the project a window is already opening reuses it: racing opens (a double
|
||||
//! click, a launch path and a restore) must not start a second index and language servers.
|
||||
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig};
|
||||
use std::sync::{Arc, Barrier};
|
||||
|
||||
#[test]
|
||||
fn racing_opens_of_one_window_share_one_project() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/Main.java"), "class Main {}\n").unwrap();
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().worker_threads(2).enable_all().build().unwrap();
|
||||
let _guard = runtime.enter();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
data_dir: tmp.path().join("data"),
|
||||
local_data_dir: tmp.path().join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: runtime.handle().clone(),
|
||||
project_environment: false,
|
||||
}));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
|
||||
let start = Arc::new(Barrier::new(4));
|
||||
let opens: Vec<_> = (0..4)
|
||||
.map(|_| {
|
||||
let (host, root, start) = (host.clone(), root.clone(), start.clone());
|
||||
std::thread::spawn(move || {
|
||||
start.wait();
|
||||
host.open_project("main", &root).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let projects: Vec<_> = opens.into_iter().map(|t| t.join().unwrap()).collect();
|
||||
|
||||
let current = host.project("main").unwrap();
|
||||
for project in &projects {
|
||||
assert!(Arc::ptr_eq(project, ¤t), "every open returns the window's one project");
|
||||
}
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
//! `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;
|
||||
@@ -214,7 +215,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 = project.canonicalize().unwrap();
|
||||
let project = dunce::canonicalize(project).unwrap();
|
||||
let events: Events = Arc::default();
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
@@ -359,7 +360,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(), f.project.to_string_lossy());
|
||||
assert_eq!(f.run("misbehave", "root", None).unwrap().unwrap(), to_ipc(&f.project));
|
||||
assert_eq!(f.status("misbehave"), PluginStatus::Active);
|
||||
}
|
||||
|
||||
@@ -1740,7 +1741,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(java.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&java))]);
|
||||
f.host.flush_events_at(Instant::now() + Duration::from_secs(3));
|
||||
};
|
||||
saved(&f);
|
||||
@@ -1793,7 +1794,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(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileOpened(to_ipc(&a))]);
|
||||
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");
|
||||
@@ -1818,7 +1819,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(a.to_string_lossy().into_owned())]);
|
||||
f.host.post_events(Some("main"), vec![WorkbenchEvent::FileSaved(to_ipc(&a))]);
|
||||
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");
|
||||
|
||||
@@ -162,7 +162,8 @@ pub fn client_capabilities() -> Value {
|
||||
"callHierarchy": { "dynamicRegistration": false },
|
||||
"typeHierarchy": { "dynamicRegistration": false }
|
||||
},
|
||||
"experimental": {}
|
||||
// rust-analyzer: report when the project has loaded (`quiescent`).
|
||||
"experimental": { "serverStatusNotification": true }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -234,6 +234,11 @@ 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, ¶ms),
|
||||
"window/logMessage" => {
|
||||
|
||||
@@ -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::OsString;
|
||||
use std::ffi::{OsStr, OsString};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -100,7 +100,8 @@ 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| resolve_rustup_proxy(&config.command, b))
|
||||
.and_then(|b| reject_dead_shim(b, &search, root)),
|
||||
};
|
||||
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())));
|
||||
@@ -136,6 +137,60 @@ 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
|
||||
@@ -219,6 +274,34 @@ 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();
|
||||
|
||||
@@ -18,6 +18,7 @@ mod inspect;
|
||||
pub mod jdtls;
|
||||
mod launch;
|
||||
mod library_source;
|
||||
mod loading;
|
||||
mod pull;
|
||||
mod scope;
|
||||
mod server;
|
||||
@@ -216,6 +217,15 @@ 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(),
|
||||
@@ -1163,8 +1173,13 @@ impl LspManager {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
let r: IdeResult<Option<lsp::Hover>> =
|
||||
self.call(t, "textDocument/hover", snap.pos_params(t, pos), Some(&cancel), DEFAULT_TIMEOUT).await;
|
||||
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;
|
||||
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));
|
||||
@@ -1254,16 +1269,17 @@ impl LspManager {
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
let mut out = Outcome::new();
|
||||
for t in targets.iter().filter(|t| snap.asks(t, pos)) {
|
||||
match self
|
||||
.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
let r = loading::while_loading(&t.server, cancel, loading::LOADING_WAIT, goto_is_empty, || {
|
||||
self.call::<_, Option<lsp::GotoDefinitionResponse>>(
|
||||
t,
|
||||
method,
|
||||
snap.pos_params(t, pos),
|
||||
cancel,
|
||||
DEFAULT_TIMEOUT,
|
||||
)
|
||||
.await
|
||||
{
|
||||
})
|
||||
.await;
|
||||
match r {
|
||||
Ok(Some(r)) => {
|
||||
let locs = convert::goto_response(&self.conv(t.enc()), r);
|
||||
let locs = library_sources(snap, pos, locs).await;
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,11 @@ 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,
|
||||
@@ -144,6 +149,8 @@ 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),
|
||||
@@ -389,22 +396,48 @@ impl Server {
|
||||
if self.running().is_none() {
|
||||
return;
|
||||
}
|
||||
let busy = !self.progress.lock().is_empty();
|
||||
let busy = self.is_busy();
|
||||
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.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();
|
||||
}
|
||||
|
||||
/// 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;
|
||||
@@ -499,6 +532,7 @@ 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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1009,6 +1009,78 @@ 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())]);
|
||||
@@ -3333,7 +3405,8 @@ 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");
|
||||
assert_eq!(s.requested("initialize")[0]["capabilities"]["experimental"], json!({}));
|
||||
let experimental = &s.requested("initialize")[0]["capabilities"]["experimental"];
|
||||
assert!(experimental.get("interactiveResolve").is_none(), "{experimental}");
|
||||
let form = h
|
||||
.mgr
|
||||
.resolve_command(
|
||||
|
||||
@@ -9,7 +9,7 @@ use std::{
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use ide_core::{IdeError, IdeResult, IpcPath, 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: String,
|
||||
pub path: IpcPath,
|
||||
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.to_string_lossy().into_owned(),
|
||||
path: self.path.clone().into(),
|
||||
version: self.version,
|
||||
dirty: self.is_dirty(),
|
||||
can_undo: !self.execution_active && !self.undo.is_empty(),
|
||||
|
||||
@@ -13,6 +13,7 @@ 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;
|
||||
@@ -935,12 +936,7 @@ fn location_from_wit(l: wit_types::Location) -> Location {
|
||||
}
|
||||
|
||||
fn doc_to_wit(d: &ProviderDocument) -> wit_prov::Document {
|
||||
wit_prov::Document {
|
||||
path: d.path.to_string_lossy().into_owned(),
|
||||
language: d.language.clone(),
|
||||
version: d.version,
|
||||
text: d.text.clone(),
|
||||
}
|
||||
wit_prov::Document { path: to_ipc(&d.path), language: d.language.clone(), version: d.version, text: d.text.clone() }
|
||||
}
|
||||
|
||||
/// Resets the budgets for the next call: fuel, deadline, cancellation.
|
||||
@@ -1012,7 +1008,7 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_prov::FileEdit {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
@@ -1131,6 +1127,27 @@ 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() }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
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};
|
||||
@@ -310,7 +311,8 @@ impl workspace::HostProject for StoreData {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
Ok(Ok(self.table.get(&this)?.root.to_string_lossy().into_owned()))
|
||||
// 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)))
|
||||
}
|
||||
|
||||
fn read_file(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<String> {
|
||||
@@ -346,7 +348,7 @@ impl editor::Host for StoreData {}
|
||||
|
||||
impl editor::HostTextEditor for StoreData {
|
||||
fn path(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
Ok(self.table.get(&this)?.ctx.path.to_string_lossy().into_owned())
|
||||
Ok(to_ipc(&self.table.get(&this)?.ctx.path))
|
||||
}
|
||||
|
||||
fn language(&mut self, this: Resource<EditorRes>) -> wasmtime::Result<String> {
|
||||
|
||||
@@ -1059,6 +1059,10 @@ 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()
|
||||
@@ -1101,6 +1105,17 @@ 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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! 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 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,
|
||||
//! Application, Spring Boot 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.
|
||||
@@ -162,6 +163,8 @@ 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>;
|
||||
|
||||
@@ -329,6 +332,8 @@ 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)
|
||||
@@ -695,6 +700,54 @@ 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.
|
||||
@@ -835,6 +888,56 @@ 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 {
|
||||
|
||||
@@ -12,6 +12,8 @@ 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;
|
||||
|
||||
@@ -80,6 +80,8 @@ 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)]
|
||||
@@ -355,6 +357,10 @@ 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)?,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
//! 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@@"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
# 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
|
||||
@@ -0,0 +1,32 @@
|
||||
# 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
|
||||
@@ -71,6 +71,62 @@ 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());
|
||||
@@ -204,7 +260,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 (`src/main/webapp`), file by file and only what changed, instead of
|
||||
/// its web resources ([`web_sources`]), 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
|
||||
@@ -232,9 +288,10 @@ 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)?;
|
||||
}
|
||||
let webapp = module.join("src/main/webapp");
|
||||
if webapp.is_dir() {
|
||||
sync_tree(&webapp, &webapp, &dest, Path::new(""), 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)?;
|
||||
}
|
||||
}
|
||||
for rel in previous.iter().filter(|p| !current.contains(*p)) {
|
||||
let path = dest.join(rel);
|
||||
@@ -246,6 +303,59 @@ 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() {
|
||||
@@ -388,7 +498,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")), context_xml(&doc, !debug))?;
|
||||
fs::write(contexts.join(format!("{context}.xml")), deployed_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()) {
|
||||
@@ -447,6 +557,48 @@ 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=&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=&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(),
|
||||
@@ -574,6 +726,83 @@ 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();
|
||||
|
||||
@@ -820,6 +820,25 @@ 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")]);
|
||||
|
||||
@@ -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="Application" factoryName="Application">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.Main" />
|
||||
<configuration name="Main" type="JUnit" factoryName="JUnit">
|
||||
<option name="MAIN_CLASS_NAME" value="shop.MainTest" />
|
||||
<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_: "Application".into() });
|
||||
assert_eq!(reason(&r, "Main"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
|
||||
let dev = get(&r, "dev");
|
||||
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
|
||||
@@ -526,14 +526,11 @@ 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="Application" />"#));
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="JUnit" />"#));
|
||||
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_: "Application".into() }
|
||||
);
|
||||
assert_eq!(reason(&import_from(dir.path(), &[]), "App"), &SkipReason::UnsupportedType { type_: "JUnit".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -702,3 +699,91 @@ 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);
|
||||
}
|
||||
@@ -479,6 +479,36 @@ 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();
|
||||
|
||||
@@ -691,6 +691,13 @@ 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));
|
||||
@@ -1301,3 +1308,29 @@ 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:?}");
|
||||
}
|
||||
}
|
||||
+103
-2
@@ -1,12 +1,14 @@
|
||||
//! 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; and the stub sets and include paths Intelephense indexes.
|
||||
//! the Intelephense storage that goes with it; the stub sets and include paths Intelephense indexes;
|
||||
//! and the PHPStan run that checks saved files.
|
||||
|
||||
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;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// A PHP version and where it comes from.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, specta::Type)]
|
||||
@@ -139,6 +141,33 @@ 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 {
|
||||
@@ -350,6 +379,78 @@ 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 });
|
||||
|
||||
@@ -140,6 +140,14 @@ 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
|
||||
|
||||
@@ -154,7 +154,11 @@ 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 { .. } => None,
|
||||
Method::Bundled { .. }
|
||||
| Method::Rustup { .. }
|
||||
| Method::PipUser { .. }
|
||||
| Method::PipTarget { .. }
|
||||
| Method::GemActive { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +433,9 @@ 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 {
|
||||
@@ -450,6 +457,7 @@ impl Tools {
|
||||
custom: BTreeMap::new(),
|
||||
found: BTreeMap::new(),
|
||||
version_timeout: Duration::from_secs(4),
|
||||
work_dir: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,10 +473,25 @@ 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)
|
||||
}
|
||||
@@ -614,7 +637,9 @@ 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 { .. } => need(self.which("gem").is_some(), "Ruby is required (https://www.ruby-lang.org)"),
|
||||
Method::Gem { .. } | Method::GemActive { .. } => {
|
||||
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)")
|
||||
}
|
||||
@@ -772,6 +797,22 @@ 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);
|
||||
@@ -868,6 +909,7 @@ 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)?,
|
||||
@@ -953,6 +995,41 @@ 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);
|
||||
@@ -1310,6 +1387,18 @@ 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 { .. }
|
||||
|
||||
@@ -49,6 +49,11 @@ 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 },
|
||||
@@ -64,6 +69,7 @@ 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",
|
||||
@@ -940,7 +946,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
display: "Ruby LSP (Shopify)",
|
||||
kind: LanguageServer,
|
||||
commands: &["ruby-lsp"],
|
||||
method: Method::Gem { gem: "ruby-lsp" },
|
||||
method: Method::GemActive { gem: "ruby-lsp" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://shopify.github.io/ruby-lsp",
|
||||
|
||||
@@ -590,6 +590,51 @@ 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() {
|
||||
|
||||
+112
-205
@@ -9,44 +9,88 @@ use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// Log "Text or hash" filter: the message (substring or regex), or a hash prefix.
|
||||
enum TextFilter {
|
||||
Plain { needle: String, match_case: bool },
|
||||
/// 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),
|
||||
Regex(regex_lite::Regex),
|
||||
}
|
||||
|
||||
impl TextFilter {
|
||||
fn new(text: &str, regex: bool, match_case: bool) -> IdeResult<Option<Self>> {
|
||||
fn new(
|
||||
r: &Repo,
|
||||
scopes: &[String],
|
||||
q: &LogQuery,
|
||||
text: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Option<Self>> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if regex {
|
||||
if q.text_regex {
|
||||
let re = regex_lite::RegexBuilder::new(text)
|
||||
.case_insensitive(!match_case)
|
||||
.case_insensitive(!q.text_match_case)
|
||||
.multi_line(true)
|
||||
.build()
|
||||
.map_err(|e| IdeError::invalid(format!("invalid regular expression: {e}")))?;
|
||||
return Ok(Some(Self::Regex(re)));
|
||||
}
|
||||
let needle = if match_case { text.to_string() } else { text.to_lowercase() };
|
||||
Ok(Some(Self::Plain { needle, match_case }))
|
||||
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 }))
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
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()))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
@@ -173,87 +217,71 @@ 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 {
|
||||
} else if q.follow && paths.len() == 1 {
|
||||
let mut ids = HashSet::new();
|
||||
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();
|
||||
let rel = &paths[0];
|
||||
let mut args = vec![
|
||||
"log".to_string(),
|
||||
"--format=%x00%H".into(),
|
||||
"--name-status".into(),
|
||||
"-z".into(),
|
||||
"--follow".into(),
|
||||
"--topo-order".into(),
|
||||
];
|
||||
if q.first_parent {
|
||||
args.push("--first-parent".into());
|
||||
}
|
||||
args.push("--end-of-options".into());
|
||||
for scope in &scopes {
|
||||
let mut args = args.clone();
|
||||
args.extend(scope.split_whitespace().map(str::to_string));
|
||||
args.extend(["--".into(), rel.clone()]);
|
||||
let output =
|
||||
crate::cli::run_cancellable(r.root(), &args.iter().map(String::as_str).collect::<Vec<_>>(), cancel)?;
|
||||
let text = output.stdout_str();
|
||||
let mut tokens = text.split('\0').peekable();
|
||||
let mut current_path = rel.clone();
|
||||
while let Some(token) = tokens.next() {
|
||||
let token = token.trim_start_matches('\n');
|
||||
if let Ok(id) = ObjectId::from_hex(token.as_bytes()) {
|
||||
ids.insert(id);
|
||||
history_paths.insert(id, current_path.clone());
|
||||
} else if token.starts_with('R') && token[1..].bytes().all(|b| b.is_ascii_digit()) {
|
||||
if let (Some(old), Some(new)) = (tokens.next(), tokens.next())
|
||||
&& new == current_path
|
||||
{
|
||||
current_path = old.to_string();
|
||||
}
|
||||
} else if ["A", "D", "M", "T", "U"].contains(&token) {
|
||||
tokens.next();
|
||||
}
|
||||
}
|
||||
} 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(t, q.text_regex, q.text_match_case)).transpose()?.flatten(),
|
||||
text: q.text.as_deref().map(|t| TextFilter::new(r, &scopes, q, t, cancel)).transpose()?.flatten(),
|
||||
matches: HashMap::new(),
|
||||
projected: HashMap::new(),
|
||||
cancel,
|
||||
@@ -584,9 +612,6 @@ 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,
|
||||
@@ -602,10 +627,7 @@ impl Visibility<'_> {
|
||||
if let Some(shown) = self.matches.get(&id) {
|
||||
return Ok(*shown);
|
||||
}
|
||||
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)
|
||||
let shown = if !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))
|
||||
{
|
||||
@@ -648,15 +670,7 @@ impl Visibility<'_> {
|
||||
}
|
||||
}))
|
||||
&& match &self.text {
|
||||
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)
|
||||
}
|
||||
Some(text) => text.matches(repo, self.index, id)?,
|
||||
None => true,
|
||||
}
|
||||
};
|
||||
@@ -865,113 +879,6 @@ 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)
|
||||
|
||||
@@ -162,14 +162,6 @@ 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")]
|
||||
@@ -672,7 +664,6 @@ 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>>,
|
||||
}
|
||||
|
||||
@@ -844,59 +835,6 @@ 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())
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::repo::{gx, to_string};
|
||||
use gix::ObjectId;
|
||||
use gix::bstr::ByteSlice;
|
||||
use ide_core::IdeResult;
|
||||
use redb::{Database, MultimapTableDefinition, ReadableDatabase, TableDefinition};
|
||||
use redb::{Database, ReadableDatabase, TableDefinition};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, OnceLock, Weak};
|
||||
@@ -14,9 +14,6 @@ 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>,
|
||||
@@ -36,9 +33,6 @@ 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>>,
|
||||
@@ -92,91 +86,6 @@ 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;
|
||||
@@ -272,11 +181,7 @@ impl LogIndex {
|
||||
}
|
||||
|
||||
pub fn flush(&mut self) {
|
||||
if self.dirty.is_empty()
|
||||
&& self.dirty_paths.is_empty()
|
||||
&& self.dirty_messages.is_empty()
|
||||
&& self.dirty_search.is_empty()
|
||||
{
|
||||
if self.dirty.is_empty() && self.dirty_messages.is_empty() {
|
||||
return;
|
||||
}
|
||||
let result = (|| -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -291,29 +196,12 @@ 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(())
|
||||
})();
|
||||
@@ -321,52 +209,7 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,6 @@ 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(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -99,8 +99,6 @@ 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();
|
||||
@@ -436,3 +434,31 @@ 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());
|
||||
}
|
||||
@@ -537,9 +537,15 @@ 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)?;
|
||||
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());
|
||||
}
|
||||
})?;
|
||||
self.set_dirty(doc, st, true);
|
||||
self.notify_changed(doc, st, &applied, &edits);
|
||||
Ok(st.doc.version())
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 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)]
|
||||
@@ -117,6 +118,43 @@ 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::*;
|
||||
@@ -186,4 +224,28 @@ 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.
|
After Width: | Height: | Size: 377 KiB |
+20
-8
@@ -130,14 +130,15 @@ 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 IntelliJ IDEA (automatic)
|
||||
### From JetBrains IDEs (automatic)
|
||||
|
||||
To use a theme you already have in IntelliJ, open **Settings › Plugins**, gear
|
||||
To use a theme you already have in a JetBrains IDE, open **Settings › Plugins**, gear
|
||||
menu:
|
||||
|
||||
- **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`
|
||||
- **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`
|
||||
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
|
||||
`~/.local/share/JetBrains` (Linux).
|
||||
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
|
||||
@@ -146,8 +147,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 chosen theme (otherwise the first) is applied. Importing
|
||||
the plugin again replaces it. Both are actions too (`plugins.importIntellijTheme`,
|
||||
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`,
|
||||
`plugins.importInstalledIntellijTheme`). The conversion is the one described
|
||||
below.
|
||||
|
||||
@@ -323,7 +324,7 @@ a token.
|
||||
|
||||
<!-- BEGIN GENERATED TOKEN REFERENCE: pnpm gen:theme-docs -->
|
||||
|
||||
224 themeable tokens: 117 in the UI theme file, 107 in the editor colour scheme file.
|
||||
235 themeable tokens: 121 in the UI theme file, 114 in the editor colour scheme file.
|
||||
|
||||
### Surfaces (UI theme)
|
||||
|
||||
@@ -406,6 +407,10 @@ 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)
|
||||
|
||||
@@ -429,6 +434,13 @@ 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` |
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #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",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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()]),
|
||||
],
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="./pythonHighlight.fixture.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,51 @@
|
||||
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") });
|
||||
});
|
||||
@@ -29,6 +29,11 @@ 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 });
|
||||
|
||||
@@ -186,3 +186,49 @@ 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");
|
||||
});
|
||||
@@ -2,6 +2,7 @@ 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",
|
||||
|
||||
+15
-9
@@ -11,9 +11,10 @@ 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: 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.
|
||||
> **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.
|
||||
|
||||
```
|
||||
plugins/
|
||||
@@ -769,7 +770,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 IntelliJ IDE…_, see [`docs/plugins/themes.md`](../docs/plugins/themes.md)). The install
|
||||
from JetBrains 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>/`
|
||||
@@ -919,8 +920,8 @@ the API itself follows _Evolving the plugin API_ below.
|
||||
|
||||
## 10. Evolving the plugin API
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -957,10 +958,15 @@ only grows:
|
||||
|
||||
Checked automatically:
|
||||
|
||||
- SDK `tests/api_frozen.rs`: `tests/api/<version>/` holds the WIT of each
|
||||
- SDK `tests/api_frozen.rs`: `tests/api/released/` holds the WIT of the latest
|
||||
released version; every interface, type, function and world import or
|
||||
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.
|
||||
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.
|
||||
- `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
|
||||
|
||||
Generated
+1
-1
@@ -103,7 +103,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-openidea-checkstyle"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "openidea-plugin-openidea-checkstyle"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
2f4608630c5e41453458ecee5bc4e2f1994a680d4307a5f002f9dd5ccc04d457
|
||||
69273e1ddfd2fff9f49c11d4fbdc60a771f8e3befe440e1623c2ab97469aa250
|
||||
@@ -1,6 +1,6 @@
|
||||
id = "openidea.checkstyle"
|
||||
name = "Checkstyle"
|
||||
version = "0.1.0"
|
||||
version = "0.1.1"
|
||||
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"
|
||||
|
||||
@@ -110,6 +110,26 @@ 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
|
||||
@@ -138,11 +158,8 @@ 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, _)| f == path || path.ends_with(f.as_str()))
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or_default();
|
||||
let problems =
|
||||
report.into_iter().find(|(f, _)| report_matches_file(f, path)).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)?;
|
||||
@@ -233,7 +250,7 @@ impl Plugin for Checkstyle {
|
||||
|
||||
register_plugin!(Checkstyle, providers);
|
||||
|
||||
#[cfg(test)]
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openidea_plugin::ProcessOutput;
|
||||
@@ -288,6 +305,45 @@ 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,6 +22,13 @@
|
||||
"--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,6 +67,10 @@
|
||||
"--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)",
|
||||
|
||||
@@ -220,11 +220,6 @@ 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
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Workbench events, delivered in batches to `event-handler.handle`.
|
||||
interface events {
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface lifecycle {
|
||||
use types.{context, error};
|
||||
+6
-2
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Writes to the IDE log, tagged with the plugin id.
|
||||
interface log {
|
||||
@@ -8,7 +8,7 @@ interface log {
|
||||
}
|
||||
|
||||
interface ui {
|
||||
use types.{notification-level};
|
||||
use types.{error, notification-level};
|
||||
|
||||
record status-item {
|
||||
text: string,
|
||||
@@ -23,6 +23,10 @@ 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
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Problems in files, shown in the editor and the Problems view.
|
||||
interface diagnostics {
|
||||
+1
-1
@@ -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.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
world plugin {
|
||||
import log;
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Editor features a plugin provides for the languages it declares under
|
||||
/// `[[contributes.providers]]` (the host calls only declared kinds). They run
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Shared types. Positions are 0-based lines and UTF-16 code unit columns,
|
||||
/// as in the editor and LSP.
|
||||
+5
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
/// Declarative views: tool windows declared
|
||||
/// under `[[contributes.views]]` and custom editors under
|
||||
@@ -133,6 +133,10 @@ 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
|
||||
+3
-1
@@ -1,4 +1,4 @@
|
||||
package openidea:plugin@1.0.0;
|
||||
package openidea:plugin@1.1.0;
|
||||
|
||||
interface workspace {
|
||||
use types.{error};
|
||||
@@ -84,6 +84,8 @@ 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>;
|
||||
@@ -3,11 +3,13 @@
|
||||
//! a field added to a record or a case added to a variant breaks every
|
||||
//! component built for the 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.
|
||||
//! `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.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
@@ -110,77 +112,83 @@ fn anonymous(r: &Resolve, id: TypeId) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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}"),
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_surface_covers_the_whole_api() {
|
||||
// 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 ")));
|
||||
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 ")));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
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}"));
|
||||
}
|
||||
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}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
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 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:?})");
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,16 @@ 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",
|
||||
|
||||
@@ -46,7 +46,9 @@ 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)
|
||||
state.recent.remove(&path)?;
|
||||
crate::jump_list::refresh(&state.app, state.recent.list());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The welcome screen's project groups.
|
||||
@@ -83,10 +85,8 @@ 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);
|
||||
let grouped = window.state::<crate::windows::WindowsState>().groups.lock().group(window.label()).is_some();
|
||||
if grouped {
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
}
|
||||
// Every project window is part of the session reopened at the next start.
|
||||
crate::window_groups::persist(window.app_handle()).await?;
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
@@ -125,6 +125,7 @@ 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))
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,6 @@ 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,
|
||||
|
||||
@@ -142,16 +142,6 @@ 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>,
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ mod commands;
|
||||
#[cfg(debug_assertions)]
|
||||
mod dev;
|
||||
mod events;
|
||||
mod jump_list;
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
mod linux_window_state;
|
||||
mod logging;
|
||||
@@ -64,6 +65,8 @@ 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);
|
||||
@@ -174,6 +177,7 @@ 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 {
|
||||
|
||||
@@ -145,6 +145,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -433,44 +433,36 @@ fn close_next(app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct SavedGroup {
|
||||
projects: Vec<PathBuf>,
|
||||
selected: usize,
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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 _write = state.groups_persist.lock().await;
|
||||
let mut previous = state.groups_persist.lock().await;
|
||||
if state.quitting() {
|
||||
return Ok(());
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
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");
|
||||
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 {
|
||||
return Ok(());
|
||||
};
|
||||
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(|_| ())
|
||||
@@ -479,6 +471,22 @@ 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(());
|
||||
@@ -489,41 +497,51 @@ 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 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()),
|
||||
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);
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) {
|
||||
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 {
|
||||
let mut labels = Vec::new();
|
||||
let mut selected = caller.to_owned();
|
||||
let mut selected = None;
|
||||
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) => w,
|
||||
None => windows::create_restored_window(app, path.clone())?,
|
||||
Some(w) => Ok(w),
|
||||
None => windows::create_restored_window(app, path.clone()),
|
||||
};
|
||||
Some(window.label().to_owned())
|
||||
match window {
|
||||
Ok(w) => Some(w.label().to_owned()),
|
||||
Err(e) => {
|
||||
tracing::warn!("cannot reopen {}: {e}", path.display());
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(label) = label {
|
||||
if group.projects.get(group.selected) == Some(path) {
|
||||
selected = label.clone();
|
||||
selected = Some(label.clone());
|
||||
}
|
||||
labels.push(label);
|
||||
}
|
||||
}
|
||||
if labels.len() > 1 {
|
||||
state.groups.lock().merge(labels, caller)?;
|
||||
select(app, caller, &selected)?;
|
||||
let selected = selected.unwrap_or_else(|| labels[0].clone());
|
||||
state.groups.lock().merge(labels, &selected)?;
|
||||
select(app, &selected, &selected)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
|
||||
Loaded 100 of 226 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user