mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:08:16 -10:00
Compare commits
90
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0024a1c705 | ||
|
|
fa7710cd4a | ||
|
|
a7cd2e80de | ||
|
|
2273c9a292 | ||
|
|
53aa2d5704 | ||
|
|
af0200e2e7 | ||
|
|
812d27e8cf | ||
|
|
48e23c012f | ||
|
|
0bc0396f80 | ||
|
|
d20be9c5cc | ||
|
|
2d3b7ebf54 | ||
|
|
6dec1252db | ||
|
|
a610982655 | ||
|
|
ed145e3046 | ||
|
|
5b46844f88 | ||
|
|
c87d2a6b55 | ||
|
|
4fbe41194f | ||
|
|
16df2ae472 | ||
|
|
cbc77941d4 | ||
|
|
3d7768f47f | ||
|
|
8687f6b1e3 | ||
|
|
85c03d33af | ||
|
|
65ca0390de | ||
|
|
b31938db00 | ||
|
|
60969eccb6 | ||
|
|
0a70b0af01 | ||
|
|
268cf01a86 | ||
|
|
fd4921c58a | ||
|
|
e04e5dd57d | ||
|
|
c5e188d1e3 | ||
|
|
d3b9a39831 | ||
|
|
59567f1af0 | ||
|
|
7f0c78893e | ||
|
|
fc6a27d4ba | ||
|
|
5e73b1634f | ||
|
|
e88e7e6459 | ||
|
|
c86928eee7 | ||
|
|
fa6d7276b8 | ||
|
|
1bc7fd06c1 | ||
|
|
280c2da48f | ||
|
|
2ec2b76d7a | ||
|
|
425a7da003 | ||
|
|
87e9bec4f9 | ||
|
|
f088a4afb0 | ||
|
|
2e6cda1d86 | ||
|
|
42968a39b3 | ||
|
|
4d086c44c9 | ||
|
|
770178966f | ||
|
|
2f2d3a25ae | ||
|
|
893cbede9c | ||
|
|
e5718d41d8 | ||
|
|
53b8cdeb51 | ||
|
|
a3bbbf90a4 | ||
|
|
9988ce4113 | ||
|
|
35c6af2367 | ||
|
|
3eabaf0d39 | ||
|
|
192e613830 | ||
|
|
4c5b6de9ad | ||
|
|
6bf1d25711 | ||
|
|
aeb3abca34 | ||
|
|
b9f3c07f9b | ||
|
|
a1dd16f26d | ||
|
|
94665528b9 | ||
|
|
809c6adcf1 | ||
|
|
2bb8f3b091 | ||
|
|
cd77c9c6fb | ||
|
|
d72f8a1506 | ||
|
|
c5749b0c17 | ||
|
|
4d224f80c9 | ||
|
|
63dd83c129 | ||
|
|
afea58c1c6 | ||
|
|
19615e00b9 | ||
|
|
c347e331ab | ||
|
|
9a6b84e287 | ||
|
|
df6fbb6d21 | ||
|
|
d94499b7ac | ||
|
|
f148c1ac6b | ||
|
|
5ad5dc2a28 | ||
|
|
fe0ee338f4 | ||
|
|
e575bf8c24 | ||
|
|
1ed9275da0 | ||
|
|
3a98d5c5be | ||
|
|
6b9ab3b323 | ||
|
|
23e468252f | ||
|
|
a6c5574194 | ||
|
|
5d63f618ef | ||
|
|
2ba07341c2 | ||
|
|
0dffad814a | ||
|
|
1c5a92f952 | ||
|
|
cfdbd3872a |
No files matched your search
@@ -0,0 +1,46 @@
|
||||
name: Plugin index
|
||||
|
||||
# Refreshes the community plugins in the latest release's plugins-index.json
|
||||
# (what released apps read) from the registry, l0ng-ai/openidea-plugins. The
|
||||
# official plugins, their key and revocations stay as the release wrote them.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
workflow_dispatch:
|
||||
# Sent by the registry when its index changes.
|
||||
repository_dispatch:
|
||||
types: [community-plugins]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: plugins-index
|
||||
|
||||
env:
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Merge the community index
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$RUNNER_TEMP"
|
||||
tag="$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName)"
|
||||
tool=openidea-plugin-linux-x86_64
|
||||
if ! gh release download "$tag" --repo "$GITHUB_REPOSITORY" -p plugins-index.json -p "$tool.gz"; then
|
||||
echo "::notice::$tag has no plugin index or openidea-plugin tool yet"
|
||||
exit 0
|
||||
fi
|
||||
gunzip "$tool.gz" && chmod +x "$tool"
|
||||
cp plugins-index.json before.json
|
||||
"./$tool" merge plugins-index.json "$COMMUNITY_PLUGINS_INDEX" --keep openidea
|
||||
if cmp -s before.json plugins-index.json; then
|
||||
echo "unchanged"
|
||||
else
|
||||
gh release upload "$tag" plugins-index.json --repo "$GITHUB_REPOSITORY" --clobber
|
||||
fi
|
||||
@@ -18,6 +18,7 @@ env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
TAG: ${{ github.event.inputs.tag || github.ref_name }}
|
||||
COMMUNITY_PLUGINS_INDEX: https://github.com/l0ng-ai/openidea-plugins/releases/download/index/index.json
|
||||
|
||||
jobs:
|
||||
# Creates (or reuses) one draft release that every platform build uploads into.
|
||||
@@ -25,6 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_id: ${{ steps.release.outputs.id }}
|
||||
official_plugin_keys: ${{ steps.plugin-keys.outputs.keys }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
@@ -35,6 +37,20 @@ jobs:
|
||||
echo "::error::tag $TAG does not match tauri.conf.json version $app"
|
||||
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
|
||||
# the secret the app takes the index's word, as development builds do.
|
||||
- id: plugin-keys
|
||||
env:
|
||||
SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
|
||||
run: |
|
||||
if [ -n "$SIGNING_KEY" ]; then
|
||||
seed="$(printf %s "$SIGNING_KEY" | base64 -d | od -An -tx1 | tr -d ' \n')"
|
||||
key="$(printf '302e020100300506032b657004220420%s' "$seed" | xxd -r -p |
|
||||
openssl pkey -inform DER -pubout -outform DER | tail -c 32 | base64)"
|
||||
echo "keys=$key" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- id: release
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
@@ -57,6 +73,8 @@ jobs:
|
||||
build:
|
||||
needs: create-release
|
||||
name: Build (${{ matrix.name }})
|
||||
env:
|
||||
OPENIDEA_OFFICIAL_PLUGIN_KEYS: ${{ needs.create-release.outputs.official_plugin_keys }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -184,7 +202,11 @@ jobs:
|
||||
# (releases/latest/download/plugins-index.json). They are not in the
|
||||
# installers: users install them from Settings › Plugins. Without the
|
||||
# signing key (secret PLUGIN_SIGNING_KEY: the file `openidea-plugin keygen`
|
||||
# writes) nothing is published and the release goes on.
|
||||
# writes) nothing is published and the release goes on. The index also lists
|
||||
# the community plugins of the registry (COMMUNITY_PLUGINS_INDEX; refreshed
|
||||
# between releases by plugins-index.yml), and the release carries the
|
||||
# openidea-plugin tool for Linux x64, which that workflow and the registry's
|
||||
# checks run.
|
||||
plugins:
|
||||
needs: create-release
|
||||
name: Official plugins
|
||||
@@ -237,11 +259,17 @@ jobs:
|
||||
version="$(sed -n 's/^version *= *"\(.*\)".*/\1/p' "$dir/plugin.toml" | head -1)"
|
||||
"$tool" pack "$dir" --key "$key" --publisher openidea --out "$out/$id-$version.oiplugin"
|
||||
done
|
||||
# The published index's revocations and earlier keys carry over.
|
||||
# The published index's revocations, earlier keys and community plugins carry over.
|
||||
curl -fsSL "https://github.com/$GITHUB_REPOSITORY/releases/latest/download/plugins-index.json" \
|
||||
-o "$out/plugins-index.json" || rm -f "$out/plugins-index.json"
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" --clobber
|
||||
# Downloads point at this release: older versions kept for older IDEs stay where they were published.
|
||||
"$tool" index "$out" --publisher openidea --name OpenIDEA --key "$key" --out "$out/plugins-index.json" \
|
||||
--download-base "https://github.com/$GITHUB_REPOSITORY/releases/download/$TAG"
|
||||
"$tool" merge "$out/plugins-index.json" "$COMMUNITY_PLUGINS_INDEX" --keep openidea ||
|
||||
echo "::warning::the community plugins were not refreshed"
|
||||
gzip -9 -c "$tool" > "$out/openidea-plugin-linux-x86_64.gz"
|
||||
gh release upload "$TAG" "$out"/*.oiplugin "$out/plugins-index.json" \
|
||||
"$out/openidea-plugin-linux-x86_64.gz" --clobber
|
||||
|
||||
# The updater manifest (latest.json), built once from the signed bundles every build uploaded.
|
||||
# Fails when a platform is missing, so a release never ships a manifest without it.
|
||||
|
||||
@@ -39,6 +39,7 @@ Visual style: dark, low-contrast greys, one cool accent
|
||||
icons from `src/ui/icons.tsx`, never emoji. Real `<button>`/`<input>`; `aria-label` on icon buttons.
|
||||
- User-visible text goes through the text catalog: add every new string to both
|
||||
`src/platform/locales/text/en.json` and `zh.json` (`textCatalog.test.ts` enforces it).
|
||||
Other locales may lack entries; their UI shows the English text until a translator adds it.
|
||||
- Keep comments sparse and purposeful; match surrounding style.
|
||||
- For GUI verification, prefer Tauri MCP; use Computer Use only when Tauri MCP is
|
||||
unavailable or cannot cover the required checks.
|
||||
|
||||
@@ -31,5 +31,10 @@ Project layout, coding rules, commands and checks are in [AGENTS.md](AGENTS.md).
|
||||
- If a change needs a product decision you can't make, ask in the issue and stop
|
||||
rather than guessing.
|
||||
|
||||
Plugins: pull requests here add or change official plugins (`plugins/official/`)
|
||||
and the plugin API. Community plugins live in their authors' repositories and
|
||||
are listed through [l0ng-ai/openidea-plugins](https://github.com/l0ng-ai/openidea-plugins);
|
||||
see [`plugins/README.md`](plugins/README.md) § 8.1.
|
||||
|
||||
A check on each PR adds `needs-info` when the issue link or the verification
|
||||
section is missing, and removes it once they're filled in.
|
||||
Generated
+27
@@ -4154,8 +4154,11 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-http",
|
||||
"ide-ssh",
|
||||
"parking_lot",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4166,6 +4169,7 @@ dependencies = [
|
||||
"tokio-util",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4244,6 +4248,7 @@ dependencies = [
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-extension",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
@@ -4267,8 +4272,11 @@ dependencies = [
|
||||
"ide-workspace",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"quick-xml",
|
||||
"regex-syntax",
|
||||
"reqwest",
|
||||
"rustix",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -7132,6 +7140,7 @@ dependencies = [
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -7490,6 +7499,12 @@ dependencies = [
|
||||
"wait-timeout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "saa"
|
||||
version = "5.6.2"
|
||||
@@ -7794,6 +7809,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.24.0"
|
||||
|
||||
@@ -86,6 +86,7 @@ zip = { version = "9", default-features = false, features = ["deflate"] }
|
||||
# search, index, language
|
||||
grep-searcher = "0.1"
|
||||
grep-regex = "0.1"
|
||||
regex-syntax = "0.8"
|
||||
grep-matcher = "0.1"
|
||||
nucleo-matcher = "0.3"
|
||||
redb = "4"
|
||||
|
||||
@@ -345,6 +345,14 @@ impl AgentManager {
|
||||
provider::set_builtin_command(&dir, id, command.as_deref())
|
||||
}
|
||||
|
||||
/// Sets the environment variables built-in `id` starts with; empty clears them.
|
||||
pub fn set_builtin_env(&self, id: &str, env: std::collections::BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let Some(dir) = self.shared.paths.custom_agents_dir.clone() else {
|
||||
return Err(IdeError::unavailable("there is no custom agents directory"));
|
||||
};
|
||||
provider::set_builtin_env(&dir, id, env)
|
||||
}
|
||||
|
||||
/// Re-reads configured/installed agents, drops cached probes, and notifies subscribers.
|
||||
pub fn reload_providers(&self) {
|
||||
let specs = provider::load(self.shared.paths.custom_agents_dir.as_deref());
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use ide_core::IpcPath;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -357,6 +358,8 @@ pub struct AgentProvider {
|
||||
pub command_read_only_reason: Option<AgentCommandReadOnlyReason>,
|
||||
/// Whether the command was found (on `PATH`).
|
||||
pub installed: bool,
|
||||
/// Environment variables it starts with (from its agent file).
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Settings (models, modes…) the agent offered last time it ran; empty before its first session.
|
||||
pub config: Vec<AgentConfigOption>,
|
||||
/// Slash commands it offered last time.
|
||||
|
||||
@@ -124,6 +124,7 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
command_editable: reason.is_none(),
|
||||
command_read_only_reason: reason,
|
||||
installed: installed(&spec.command),
|
||||
env: spec.env.iter().cloned().collect(),
|
||||
config: vec![],
|
||||
commands: vec![],
|
||||
source: spec.source.clone(),
|
||||
@@ -133,6 +134,16 @@ pub(crate) fn describe(spec: &ProviderSpec) -> AgentProvider {
|
||||
/// Overrides built-in `id`'s command through its agent file, preserving other
|
||||
/// customizations. `None` restores the built-in command and removes a redundant file.
|
||||
pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&str>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, builtin| agent.command = command.unwrap_or(&builtin.command).to_owned())
|
||||
}
|
||||
|
||||
/// The environment built-in `id` starts with, through its agent file (empty clears it).
|
||||
pub(crate) fn set_builtin_env(custom_dir: &Path, id: &str, env: BTreeMap<String, String>) -> IdeResult<()> {
|
||||
update_builtin(custom_dir, id, |agent, _| agent.env = env)
|
||||
}
|
||||
|
||||
/// Changes built-in `id`'s agent file; a file left with only the built-in's values is removed.
|
||||
fn update_builtin(custom_dir: &Path, id: &str, change: impl FnOnce(&mut CustomAgent, &ProviderSpec)) -> IdeResult<()> {
|
||||
let Some(builtin) = builtin_spec(id) else {
|
||||
return Err(IdeError::invalid(format!("{id} is not a built-in agent")));
|
||||
};
|
||||
@@ -152,7 +163,8 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
|
||||
if agent.registry.is_some() {
|
||||
return Err(IdeError::invalid(format!("{id} is installed from the ACP Registry")));
|
||||
}
|
||||
if command.is_none()
|
||||
change(&mut agent, &builtin);
|
||||
if agent.command == builtin.command
|
||||
&& agent.name.as_deref() == Some(builtin.name.as_str())
|
||||
&& agent.driver == builtin.driver
|
||||
&& agent.args == builtin.args
|
||||
@@ -164,7 +176,6 @@ pub(crate) fn set_builtin_command(custom_dir: &Path, id: &str, command: Option<&
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
agent.command = command.unwrap_or(&builtin.command).to_owned();
|
||||
agent.write(&file)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -368,6 +379,23 @@ mod tests {
|
||||
assert!(installed("sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_env_writes_and_clears_the_environment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let env = BTreeMap::from([("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
set_builtin_env(dir.path(), "claude", env.clone()).unwrap();
|
||||
let claude = load(Some(dir.path())).into_iter().find(|s| s.id == "claude").unwrap();
|
||||
assert_eq!(claude.env, vec![("CLAUDE_CODE_ENTRYPOINT".to_owned(), "openidea".to_owned())]);
|
||||
assert_eq!(describe(&claude).env, env);
|
||||
// The command override and the environment are kept independently.
|
||||
set_builtin_command(dir.path(), "claude", Some("/opt/claude")).unwrap();
|
||||
set_builtin_command(dir.path(), "claude", None).unwrap();
|
||||
assert!(dir.path().join("claude.toml").is_file());
|
||||
set_builtin_env(dir.path(), "claude", BTreeMap::new()).unwrap();
|
||||
assert!(!dir.path().join("claude.toml").exists());
|
||||
set_builtin_env(dir.path(), "not-a-builtin", env).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_builtin_command_overrides_and_clears_a_builtin() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -10,6 +10,7 @@ pub use fingerprint::{LaunchFingerprint, launch_fingerprint};
|
||||
|
||||
use crate::process::{self, Stream};
|
||||
use crate::{BuildModel, maven};
|
||||
use ide_core::settings::CMakeProfile;
|
||||
use ide_core::{CancellationToken, Diagnostic, IdeError, IdeResult, Pos, Range, Severity};
|
||||
use parse::{ColUnit, Format, OutputParser, RawMessage};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -41,6 +42,8 @@ pub enum BuildSystem {
|
||||
Go,
|
||||
#[serde(rename = "typescript")]
|
||||
TypeScript,
|
||||
#[serde(rename = "cmake")]
|
||||
CMake,
|
||||
}
|
||||
|
||||
/// One command of a build.
|
||||
@@ -152,6 +155,8 @@ fn detect_dir(dir: &Path, model: &BuildModel, root: &Path) -> Option<BuildSystem
|
||||
Some(BuildSystem::Go)
|
||||
} else if has(dir, &["tsconfig.json", "package.json"]) {
|
||||
Some(BuildSystem::TypeScript)
|
||||
} else if has(dir, &["CMakeLists.txt"]) {
|
||||
Some(BuildSystem::CMake)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
@@ -176,7 +181,14 @@ fn ancestors_within<'a>(root: &'a Path, file: &'a Path) -> impl Iterator<Item =
|
||||
}
|
||||
|
||||
/// The build of `root`, or of the module containing `file` (Build Module / Recompile).
|
||||
pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path>) -> IdeResult<BuildPlan> {
|
||||
/// `cmake`: the CMake profiles of the settings (the first one is built), if any.
|
||||
pub fn plan(
|
||||
root: &Path,
|
||||
model: &BuildModel,
|
||||
kind: BuildKind,
|
||||
file: Option<&Path>,
|
||||
cmake: Option<&[CMakeProfile]>,
|
||||
) -> IdeResult<BuildPlan> {
|
||||
let project_name = root.file_name().map_or_else(|| "project".to_owned(), |n| n.to_string_lossy().into_owned());
|
||||
let module_dir = match file {
|
||||
Some(f) => {
|
||||
@@ -192,7 +204,9 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
|
||||
let system = detect_dir(&dir, model, root)
|
||||
.or_else(|| if model.gradle.is_some() { Some(BuildSystem::Gradle) } else { None })
|
||||
.or_else(|| model.maven.as_ref().filter(|m| !m.projects.is_empty()).map(|_| BuildSystem::Maven))
|
||||
.ok_or_else(|| IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript) found"))?;
|
||||
.ok_or_else(|| {
|
||||
IdeError::not_found("no supported build system (Maven, Gradle, Cargo, Go, TypeScript, CMake) found")
|
||||
})?;
|
||||
let module = module_dir.as_deref().filter(|d| *d != root);
|
||||
let title = match module {
|
||||
Some(d) => format!(
|
||||
@@ -209,6 +223,7 @@ pub fn plan(root: &Path, model: &BuildModel, kind: BuildKind, file: Option<&Path
|
||||
// Go builds the file's package (a module holds many).
|
||||
BuildSystem::Go => go_steps(file.and_then(Path::parent).unwrap_or(&dir), kind),
|
||||
BuildSystem::TypeScript => ts_steps(&dir, kind),
|
||||
BuildSystem::CMake => cmake_steps(&dir, kind, cmake.and_then(<[_]>::first)),
|
||||
};
|
||||
Ok(BuildPlan { system, title, steps })
|
||||
}
|
||||
@@ -302,6 +317,50 @@ fn cargo_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
}
|
||||
}
|
||||
|
||||
/// CLion's Build Project for a CMake project: configures the build directory when it has no
|
||||
/// `CMakeCache.txt` yet, then `cmake --build` (`--clean-first` to rebuild). The directory and
|
||||
/// options come from the CMake profile; without one, a configured directory of the project
|
||||
/// (the first by name), else `build`, as the generated run configurations use.
|
||||
fn cmake_steps(dir: &Path, kind: BuildKind, profile: Option<&CMakeProfile>) -> Vec<BuildStep> {
|
||||
let build_dir = match profile {
|
||||
Some(p) if !p.build_dir.trim().is_empty() => p.build_dir.trim().to_owned(),
|
||||
Some(p) => format!("cmake-build-{}", p.build_type.to_lowercase()),
|
||||
None => {
|
||||
let mut configured: Vec<String> = std::fs::read_dir(dir)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter(|e| e.path().join("CMakeCache.txt").is_file())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
configured.sort();
|
||||
configured.into_iter().next().unwrap_or_else(|| "build".into())
|
||||
}
|
||||
};
|
||||
let build_type = profile.map_or("Debug", |p| p.build_type.as_str());
|
||||
let env = profile.map(|p| p.env.clone()).unwrap_or_default();
|
||||
let mut steps = Vec::new();
|
||||
if !dir.join(&build_dir).join("CMakeCache.txt").is_file() {
|
||||
let mut configure = BuildStep::new("cmake", &["-S", ".", "-B", &build_dir], dir, Format::Gcc);
|
||||
if let Some(p) = profile.filter(|p| !p.generator.trim().is_empty()) {
|
||||
configure.args.extend(["-G".into(), p.generator.clone()]);
|
||||
}
|
||||
configure.args.push(format!("-DCMAKE_BUILD_TYPE={build_type}"));
|
||||
configure.args.push("-DCMAKE_EXPORT_COMPILE_COMMANDS=ON".into());
|
||||
configure.args.extend(profile.map(|p| p.cmake_args.clone()).unwrap_or_default());
|
||||
configure.env = env.clone();
|
||||
steps.push(configure);
|
||||
}
|
||||
let mut build = BuildStep::new("cmake", &["--build", &build_dir, "--config", build_type], dir, Format::Gcc);
|
||||
if kind == BuildKind::Rebuild {
|
||||
build.args.push("--clean-first".into());
|
||||
}
|
||||
build.args.extend(profile.map(|p| p.build_args.clone()).unwrap_or_default());
|
||||
build.env = env;
|
||||
steps.push(build);
|
||||
steps
|
||||
}
|
||||
|
||||
fn go_steps(dir: &Path, kind: BuildKind) -> Vec<BuildStep> {
|
||||
let module_root = dir.ancestors().find(|d| d.join("go.mod").is_file()).unwrap_or(dir);
|
||||
let target = if module_root == dir { "./...".to_owned() } else { format!("./{}/...", rel_str(module_root, dir)) };
|
||||
@@ -687,7 +746,7 @@ fn launch_plan(root: &Path, model: &BuildModel, module: &Path, test: bool) -> Id
|
||||
} else {
|
||||
BuildKind::Build
|
||||
};
|
||||
let mut plan = plan(root, model, kind, Some(module))?;
|
||||
let mut plan = plan(root, model, kind, Some(module), None)?;
|
||||
if plan.system == BuildSystem::Maven && !test {
|
||||
for step in &mut plan.steps {
|
||||
if let Some(goal) = step.args.last_mut() {
|
||||
@@ -796,12 +855,12 @@ mod tests {
|
||||
write(&root, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&root, "core/src/main/java/A.java", "class A {}");
|
||||
let model = crate::load(&root, &tmp.path().join("cache"), None);
|
||||
let p = plan(&root, &model, BuildKind::Build, None).unwrap();
|
||||
let p = plan(&root, &model, BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(p.title, "demo");
|
||||
assert_eq!(p.steps.len(), 1);
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "test-compile"]);
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java"))).unwrap();
|
||||
let p = plan(&root, &model, BuildKind::Rebuild, Some(&root.join("core/src/main/java/A.java")), None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["-B", "-Dstyle.color=never", "-pl", "core", "-am", "clean", "test-compile"]);
|
||||
assert_eq!(p.steps[0].cwd, root);
|
||||
assert_eq!(p.title, "demo: build module 'core'");
|
||||
@@ -810,24 +869,24 @@ mod tests {
|
||||
write(&g, "settings.gradle", "include 'app'");
|
||||
write(&g, "app/build.gradle", "apply plugin: 'java'");
|
||||
let model = crate::load(&g, &tmp.path().join("cache2"), None);
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java"))).unwrap();
|
||||
let p = plan(&g, &model, BuildKind::Build, Some(&g.join("app/src/A.java")), None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Gradle);
|
||||
assert_eq!(p.steps[0].args, ["--console=plain", ":app:classes", ":app:testClasses"]);
|
||||
assert_eq!(
|
||||
plan(&g, &model, BuildKind::Artifacts, None).unwrap().steps[0].args,
|
||||
plan(&g, &model, BuildKind::Artifacts, None, None).unwrap().steps[0].args,
|
||||
["--console=plain", "assemble"]
|
||||
);
|
||||
|
||||
let c = tmp.path().join("c");
|
||||
write(&c, "Cargo.toml", "[package]\nname='c'");
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None).unwrap();
|
||||
let p = plan(&c, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Cargo);
|
||||
assert_eq!(p.steps.len(), 2);
|
||||
assert_eq!(p.steps[0].args[0], "clean");
|
||||
|
||||
let go = tmp.path().join("go");
|
||||
write(&go, "go.mod", "module x");
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go"))).unwrap();
|
||||
let p = plan(&go, &BuildModel::default(), BuildKind::Build, Some(&go.join("pkg/a/a.go")), None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["build", "./pkg/a/..."]);
|
||||
assert_eq!(p.steps[0].cwd, go);
|
||||
|
||||
@@ -835,19 +894,51 @@ mod tests {
|
||||
write(&ts, "package.json", r#"{"scripts":{"build":"vite build"}}"#);
|
||||
write(&ts, "tsconfig.json", "{}");
|
||||
write(&ts, "pnpm-lock.yaml", "");
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.steps[0].args, ["--noEmit", "--pretty", "false", "-p", "."]);
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None).unwrap();
|
||||
let p = plan(&ts, &BuildModel::default(), BuildKind::Artifacts, None, None).unwrap();
|
||||
assert_eq!(
|
||||
(p.steps[0].program.as_str(), p.steps[0].args.as_slice()),
|
||||
("pnpm", &["run".to_owned(), "build".to_owned()][..])
|
||||
);
|
||||
|
||||
let cm = tmp.path().join("cm");
|
||||
write(&cm, "CMakeLists.txt", "project(app)");
|
||||
let args = |p: &BuildPlan| p.steps.iter().map(|s| s.args.join(" ")).collect::<Vec<_>>();
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::CMake);
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
[
|
||||
"-S . -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_EXPORT_COMPILE_COMMANDS=ON",
|
||||
"--build build --config Debug"
|
||||
],
|
||||
"an unconfigured project is configured first"
|
||||
);
|
||||
write(&cm, "cmake-build-release/CMakeCache.txt", "");
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Rebuild, None, None).unwrap();
|
||||
assert_eq!(
|
||||
args(&p),
|
||||
["--build cmake-build-release --config Debug --clean-first"],
|
||||
"a configured directory is reused"
|
||||
);
|
||||
let profile = CMakeProfile {
|
||||
build_type: "Release".into(),
|
||||
generator: "Ninja".into(),
|
||||
build_args: vec!["-j".into(), "8".into()],
|
||||
..CMakeProfile::default()
|
||||
};
|
||||
let p =
|
||||
plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(std::slice::from_ref(&profile))).unwrap();
|
||||
assert_eq!(args(&p), ["--build cmake-build-release --config Release -j 8"]);
|
||||
let p = plan(&cm, &BuildModel::default(), BuildKind::Build, None, Some(&[CMakeProfile::default()])).unwrap();
|
||||
assert_eq!(p.steps[0].args[..4], ["-S", ".", "-B", "cmake-build-debug"]);
|
||||
|
||||
let siblings = tmp.path().join("siblings");
|
||||
write(&siblings, "core/pom.xml", "<project><artifactId>core</artifactId></project>");
|
||||
write(&siblings, "system/pom.xml", "<project><artifactId>system</artifactId></project>");
|
||||
let model = crate::load(&siblings, &tmp.path().join("cache3"), None);
|
||||
let p = plan(&siblings, &model, BuildKind::Build, None).unwrap();
|
||||
let p = plan(&siblings, &model, BuildKind::Build, None, None).unwrap();
|
||||
assert_eq!(p.system, BuildSystem::Maven);
|
||||
assert_eq!(
|
||||
p.steps.iter().map(|s| s.cwd.clone()).collect::<Vec<_>>(),
|
||||
@@ -856,7 +947,7 @@ mod tests {
|
||||
|
||||
let none = tmp.path().join("none");
|
||||
std::fs::create_dir_all(&none).unwrap();
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None).is_err());
|
||||
assert!(plan(&none, &BuildModel::default(), BuildKind::Build, None, None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -22,6 +22,8 @@ pub(crate) enum Format {
|
||||
Tsc,
|
||||
/// `go build`: `./a.go:12:5: msg`.
|
||||
Go,
|
||||
/// GCC / Clang (through `cmake --build`): `src/a.cpp:12:5: error: msg`.
|
||||
Gcc,
|
||||
}
|
||||
|
||||
/// What a printed column counts.
|
||||
@@ -296,6 +298,17 @@ fn go_line(line: &str) -> Option<RawMessage> {
|
||||
(!msg.is_empty()).then(|| RawMessage::new(Severity::Error, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// A GCC / Clang diagnostic line; notes and the context lines after it are left as output.
|
||||
fn gcc_line(line: &str) -> Option<RawMessage> {
|
||||
let (path, ln, col, rest) = path_line_col(line)?;
|
||||
let rest = rest.strip_prefix(':')?.trim_start();
|
||||
let (severity, msg) =
|
||||
[("fatal error:", Severity::Error), ("error:", Severity::Error), ("warning:", Severity::Warning)]
|
||||
.into_iter()
|
||||
.find_map(|(prefix, severity)| rest.strip_prefix(prefix).map(|m| (severity, m.trim())))?;
|
||||
(!msg.is_empty()).then(|| RawMessage::new(severity, msg, ColUnit::Byte).at(path, ln, col))
|
||||
}
|
||||
|
||||
/// rustc's JSON diagnostic (`reason: compiler-message`) → message and its rendered text.
|
||||
fn cargo_json(v: &Value) -> Option<(Option<RawMessage>, String)> {
|
||||
if v.get("reason")?.as_str()? != "compiler-message" {
|
||||
@@ -410,6 +423,7 @@ impl OutputParser {
|
||||
Format::Gradle => javac_header(&line).or_else(|| kotlin_line(&line)),
|
||||
Format::Tsc => tsc_line(&line),
|
||||
Format::Go => go_line(&line),
|
||||
Format::Gcc => gcc_line(&line),
|
||||
Format::Cargo => None,
|
||||
};
|
||||
if let Some(msg) = parsed {
|
||||
@@ -563,6 +577,24 @@ mod tests {
|
||||
assert_eq!(out[2].text, "Cannot find name 'foo'.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcc_errors_and_warnings() {
|
||||
let out = parse(
|
||||
Format::Gcc,
|
||||
"[1/2] Building CXX object CMakeFiles/app.dir/main.cpp.o\n\
|
||||
/p/src/main.cpp:12:5: error: use of undeclared identifier 'foo'\n\
|
||||
\x20 12 | foo();\n\
|
||||
/p/src/util.h:3:10: warning: unused variable 'x' [-Wunused-variable]\n\
|
||||
/p/src/main.cpp:4:10: fatal error: 'missing.h' file not found\n\
|
||||
/p/src/main.cpp:2:1: note: in file included from here\n",
|
||||
);
|
||||
assert_eq!(out.len(), 3, "{out:?}");
|
||||
assert_eq!(out[0].severity, Severity::Error);
|
||||
assert_eq!(out[0].text, "use of undeclared identifier 'foo'");
|
||||
assert_eq!(out[1].severity, Severity::Warning);
|
||||
assert_eq!(out[2].text, "'missing.h' file not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_errors() {
|
||||
let out = parse(
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
//! A Gradle project's resolved dependencies for the Dependency Analyzer:
|
||||
//! `gradle <project>:dependencies --configuration <name>`, parsed from its tree report.
|
||||
|
||||
use crate::gradle;
|
||||
use crate::process;
|
||||
use crate::sync::{SyncOptions, error_summary};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
/// One line of the report: an occurrence of a dependency where it is requested.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GradleDependency {
|
||||
/// `group`, empty for a project dependency.
|
||||
pub group: String,
|
||||
/// Artifact name, or the project path (`:core`) of a project dependency.
|
||||
pub name: String,
|
||||
/// The version asked for here; `None` when a platform or constraint supplies it.
|
||||
pub requested: Option<String>,
|
||||
/// The version Gradle selected (`-> 1.2`), when it differs from the request.
|
||||
pub selected: Option<String>,
|
||||
/// A project of the build (`project :core`, or a module substituted by one).
|
||||
pub project: bool,
|
||||
/// `(*)`: listed with its dependencies earlier in the tree.
|
||||
pub repeated: bool,
|
||||
/// `(c)`: a dependency constraint rather than a dependency.
|
||||
pub constraint: bool,
|
||||
/// `(n)` / `FAILED`: not resolved.
|
||||
pub unresolved: bool,
|
||||
pub children: Vec<GradleDependency>,
|
||||
}
|
||||
|
||||
/// Parses the tree of a `dependencies --configuration` report (lines `+--- g:a:1.0 -> 1.1 (*)`).
|
||||
pub fn parse_report(output: &str) -> Vec<GradleDependency> {
|
||||
let mut roots: Vec<GradleDependency> = Vec::new();
|
||||
// Path of child indexes from the roots to the last node of each depth.
|
||||
let mut path: Vec<usize> = Vec::new();
|
||||
for line in output.lines() {
|
||||
let Some((depth, text)) = tree_line(line) else { continue };
|
||||
let node = parse_entry(text);
|
||||
path.truncate(depth);
|
||||
if depth > path.len() {
|
||||
continue; // A line deeper than its parent allows: not part of a well-formed tree.
|
||||
}
|
||||
let siblings = if depth == 0 {
|
||||
&mut roots
|
||||
} else {
|
||||
let mut list = &mut roots;
|
||||
for &i in &path[..depth - 1] {
|
||||
list = &mut list[i].children;
|
||||
}
|
||||
let Some(parent) = list.get_mut(path[depth - 1]) else { continue };
|
||||
&mut parent.children
|
||||
};
|
||||
siblings.push(node);
|
||||
path.push(siblings.len() - 1);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
/// `| \--- g:a:1.0` → (1, "g:a:1.0").
|
||||
fn tree_line(line: &str) -> Option<(usize, &str)> {
|
||||
let mut rest = line;
|
||||
let mut depth = 0;
|
||||
loop {
|
||||
if let Some(r) = rest.strip_prefix("+--- ").or_else(|| rest.strip_prefix("\\--- ")) {
|
||||
return Some((depth, r.trim()));
|
||||
}
|
||||
rest = rest.strip_prefix("| ").or_else(|| rest.strip_prefix(" "))?;
|
||||
depth += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_entry(text: &str) -> GradleDependency {
|
||||
let mut d = GradleDependency::default();
|
||||
let mut text = text.trim();
|
||||
loop {
|
||||
if let Some(t) = text.strip_suffix(" (*)") {
|
||||
d.repeated = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (c)") {
|
||||
d.constraint = true;
|
||||
text = t;
|
||||
} else if let Some(t) = text.strip_suffix(" (n)").or_else(|| text.strip_suffix(" FAILED")) {
|
||||
d.unresolved = true;
|
||||
text = t;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
text = text.trim_end();
|
||||
}
|
||||
let (left, right) = match text.split_once(" -> ") {
|
||||
Some((l, r)) => (l.trim(), Some(r.trim())),
|
||||
None => (text, None),
|
||||
};
|
||||
if let Some(project) = left.strip_prefix("project ") {
|
||||
d.project = true;
|
||||
d.name = project_path(project);
|
||||
} else {
|
||||
let mut parts = left.splitn(3, ':');
|
||||
d.group = parts.next().unwrap_or_default().to_owned();
|
||||
d.name = parts.next().unwrap_or_default().to_owned();
|
||||
d.requested = parts.next().filter(|v| !v.is_empty()).map(str::to_owned);
|
||||
}
|
||||
if let Some(right) = right {
|
||||
if let Some(project) = right.strip_prefix("project ") {
|
||||
// A module substituted by a project of the build.
|
||||
d.project = true;
|
||||
d.selected = Some(project_path(project));
|
||||
} else {
|
||||
d.selected = Some(right.to_owned());
|
||||
}
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// `:core`, or `':core'` as Gradle 9 quotes it.
|
||||
fn project_path(text: &str) -> String {
|
||||
let t = text.trim();
|
||||
t.strip_prefix('\'').and_then(|t| t.strip_suffix('\'')).unwrap_or(t).to_owned()
|
||||
}
|
||||
|
||||
/// Runs the `dependencies` report of `project` (a Gradle path, `:` for the root) for
|
||||
/// `configuration` and returns its tree.
|
||||
pub fn gradle_dependencies(
|
||||
root: &Path,
|
||||
options: &SyncOptions,
|
||||
project: &str,
|
||||
configuration: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Vec<GradleDependency>> {
|
||||
if !configuration.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') || configuration.is_empty() {
|
||||
return Err(IdeError::invalid(format!("not a configuration name: {configuration}")));
|
||||
}
|
||||
if !project.starts_with(':') || project.chars().any(char::is_whitespace) {
|
||||
return Err(IdeError::invalid(format!("not a Gradle project path: {project}")));
|
||||
}
|
||||
let model = gradle::load(root).ok_or_else(|| IdeError::not_found("no Gradle build in this project"))?;
|
||||
let task = if project == ":" { ":dependencies".to_owned() } else { format!("{project}:dependencies") };
|
||||
let mut args: Vec<String> =
|
||||
vec!["--console=plain".into(), "-q".into(), task, "--configuration".into(), configuration.into()];
|
||||
if options.offline {
|
||||
args.push("--offline".into());
|
||||
}
|
||||
let out = process::run(
|
||||
options.gradle_command.as_deref().unwrap_or(&model.command),
|
||||
&args,
|
||||
&root.join(&model.dir),
|
||||
&options.env,
|
||||
cancel,
|
||||
|_, _| {},
|
||||
)?;
|
||||
if !out.success {
|
||||
return Err(IdeError::unavailable(error_summary(&out.text)));
|
||||
}
|
||||
Ok(parse_report(&out.text))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn dep(group: &str, name: &str, requested: Option<&str>) -> GradleDependency {
|
||||
GradleDependency {
|
||||
group: group.into(),
|
||||
name: name.into(),
|
||||
requested: requested.map(Into::into),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_tree_with_selected_versions_and_markers() {
|
||||
let report = "\
|
||||
------------------------------------------------------------
|
||||
Project ':app'
|
||||
------------------------------------------------------------
|
||||
|
||||
runtimeClasspath - Runtime classpath of source set 'main'.
|
||||
+--- org.springframework.boot:spring-boot-starter-web -> 3.2.0
|
||||
| +--- org.springframework.boot:spring-boot-starter:3.2.0
|
||||
| | \\--- org.yaml:snakeyaml:2.2
|
||||
| \\--- com.fasterxml.jackson.core:jackson-databind:2.15.0 -> 2.15.3 (*)
|
||||
+--- project ':core'
|
||||
| \\--- com.google.guava:guava:{strictly 32.1.3-jre} -> 32.1.3-jre (c)
|
||||
+--- org.example:legacy:1.0 -> project :legacy
|
||||
\\--- org.missing:gone:9 FAILED
|
||||
|
||||
(c) - A dependency constraint, not a dependency. The dependency affected by the constraint occurs elsewhere in the tree.
|
||||
(*) - Indicates repeated occurrences of a transitive dependency subtree. Gradle expands transitive dependency subtrees only once per project; repeat occurrences only display the root of the subtree, followed by this annotation.
|
||||
";
|
||||
let tree = parse_report(report);
|
||||
assert_eq!(tree.len(), 4);
|
||||
let web = &tree[0];
|
||||
assert_eq!((web.group.as_str(), web.name.as_str()), ("org.springframework.boot", "spring-boot-starter-web"));
|
||||
assert_eq!((web.requested.as_deref(), web.selected.as_deref()), (None, Some("3.2.0")));
|
||||
assert_eq!(web.children.len(), 2);
|
||||
assert_eq!(web.children[0].children, vec![dep("org.yaml", "snakeyaml", Some("2.2"))]);
|
||||
let databind = &web.children[1];
|
||||
assert_eq!((databind.requested.as_deref(), databind.selected.as_deref()), (Some("2.15.0"), Some("2.15.3")));
|
||||
assert!(databind.repeated);
|
||||
let core = &tree[1];
|
||||
assert!(core.project && core.group.is_empty());
|
||||
assert_eq!(core.name, ":core");
|
||||
let guava = &core.children[0];
|
||||
assert!(guava.constraint);
|
||||
assert_eq!(guava.requested.as_deref(), Some("{strictly 32.1.3-jre}"));
|
||||
let legacy = &tree[2];
|
||||
assert!(legacy.project);
|
||||
assert_eq!((legacy.name.as_str(), legacy.selected.as_deref()), ("legacy", Some(":legacy")));
|
||||
assert!(tree[3].unresolved);
|
||||
assert_eq!(tree[3].requested.as_deref(), Some("9"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_configuration_has_no_dependencies() {
|
||||
assert!(
|
||||
parse_report("compileClasspath - Compile classpath for source set 'main'.\nNo dependencies\n").is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_names_that_are_not_configurations_or_projects() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let options = SyncOptions::default();
|
||||
let cancel = CancellationToken::new();
|
||||
assert!(gradle_dependencies(tmp.path(), &options, ":", "x --init-script y", &cancel).is_err());
|
||||
assert!(gradle_dependencies(tmp.path(), &options, "app", "runtimeClasspath", &cancel).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn runs_the_report_of_the_project_and_configuration() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
std::fs::write(root.join("settings.gradle"), "include 'app'").unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "").unwrap();
|
||||
let launcher = root.join("fake-gradle");
|
||||
std::fs::write(&launcher, "#!/bin/sh\necho \"$@\" > args\necho '\\--- org.a:b:1.0'\n").unwrap();
|
||||
std::fs::set_permissions(&launcher, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let options = SyncOptions {
|
||||
gradle_command: Some(launcher.to_string_lossy().into_owned()),
|
||||
offline: true,
|
||||
..Default::default()
|
||||
};
|
||||
let tree =
|
||||
gradle_dependencies(root, &options, ":app", "testRuntimeClasspath", &CancellationToken::new()).unwrap();
|
||||
assert_eq!(tree, vec![dep("org.a", "b", Some("1.0"))]);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("args")).unwrap().trim(),
|
||||
"--console=plain -q :app:dependencies --configuration testRuntimeClasspath --offline"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ pub fn build(
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Outcome> {
|
||||
std::fs::create_dir_all(generation)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None)?;
|
||||
let mut plan = compile::plan(root, model, BuildKind::Build, None, None)?;
|
||||
if plan.system != compile::BuildSystem::Maven {
|
||||
return Err(IdeError::invalid("Kotlin bridge requires a Maven build root"));
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
pub mod application_update;
|
||||
pub mod compile;
|
||||
pub mod gradle;
|
||||
mod gradle_deps;
|
||||
pub mod kotlin_bridge;
|
||||
pub mod maven;
|
||||
pub mod pom_edit;
|
||||
@@ -17,6 +18,7 @@ pub mod web;
|
||||
mod xml;
|
||||
|
||||
pub use gradle::{GradleModel, GradleProject, GradleTask};
|
||||
pub use gradle_deps::{GradleDependency, gradle_dependencies};
|
||||
pub use maven::{MavenDependency, MavenModel, MavenPlugin, MavenProfile, MavenProject, Omission};
|
||||
pub use sync::{SyncOptions, SyncProgress, maven_profiles, save_maven_profiles, sync_gradle, sync_maven};
|
||||
|
||||
@@ -53,7 +55,8 @@ pub fn load_linked(
|
||||
settings: &ide_core::settings::MavenSettings,
|
||||
) -> BuildModel {
|
||||
let maven = maven::is_maven_project(root).then(|| {
|
||||
let mut m = maven::load_with_repo(root, maven_repo.or_else(maven::default_local_repo));
|
||||
let profiles = sync::maven_profiles(cache_dir);
|
||||
let mut m = maven::load_with(root, maven_repo.or_else(maven::default_local_repo), &profiles);
|
||||
maven::UnlinkedProjects::from_model(&m, settings).filter(&mut m);
|
||||
sync::apply_maven_cache(root, cache_dir, &mut m);
|
||||
m
|
||||
|
||||
@@ -460,6 +460,8 @@ const MAX_PARENT_DEPTH: usize = 10;
|
||||
|
||||
struct Resolver {
|
||||
local_repo: Option<PathBuf>,
|
||||
/// The chosen profiles (`-P` entries; `!id` / `-id` deactivates).
|
||||
profiles: Vec<String>,
|
||||
poms: HashMap<PathBuf, Option<RawPom>>,
|
||||
contexts: HashMap<PathBuf, Inherited>,
|
||||
}
|
||||
@@ -484,8 +486,16 @@ pub fn local_repository_of(settings: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
impl Resolver {
|
||||
fn new(local_repo: Option<PathBuf>) -> Self {
|
||||
Self { local_repo, poms: HashMap::new(), contexts: HashMap::new() }
|
||||
fn new(local_repo: Option<PathBuf>, profiles: &[String]) -> Self {
|
||||
Self { local_repo, profiles: profiles.to_vec(), poms: HashMap::new(), contexts: HashMap::new() }
|
||||
}
|
||||
|
||||
/// The active profiles of a POM: the chosen ones, and the `activeByDefault` ones not turned
|
||||
/// off while none of the POM's other profiles is chosen (Maven's rule).
|
||||
fn active<'a>(&self, profiles: &'a [RawProfile]) -> impl Iterator<Item = &'a RawProfile> {
|
||||
let chosen = |id: &str| profile_choice(&self.profiles, id);
|
||||
let any_chosen = profiles.iter().any(|p| chosen(&p.id) == Some(true));
|
||||
profiles.iter().filter(move |p| chosen(&p.id).unwrap_or(p.active_by_default && !any_chosen))
|
||||
}
|
||||
|
||||
fn pom(&mut self, path: &Path) -> Option<RawPom> {
|
||||
@@ -550,7 +560,7 @@ impl Resolver {
|
||||
if pom.compiler_release.is_some() {
|
||||
ctx.compiler_release = pom.compiler_release.clone();
|
||||
}
|
||||
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
||||
for p in self.active(&pom.profiles) {
|
||||
merge_by(&mut ctx.properties, &p.properties, |(k, _)| k.clone());
|
||||
merge_by(&mut ctx.deps, &p.deps, RawDep::key);
|
||||
merge_plugins(&mut ctx.plugins, &p.plugins);
|
||||
@@ -666,7 +676,7 @@ pub(crate) fn launch_inputs(
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo));
|
||||
let mut resolver = Resolver::new(local_repo.or_else(default_local_repo), &[]);
|
||||
let mut out = Vec::new();
|
||||
for project in projects {
|
||||
let dir = root.join(&project.dir);
|
||||
@@ -1055,8 +1065,32 @@ pub fn load(root: &Path) -> MavenModel {
|
||||
load_with_repo(root, default_local_repo())
|
||||
}
|
||||
|
||||
/// [`load`] with the chosen `profiles` (`-P` entries; `!id` deactivates): their modules,
|
||||
/// dependencies and properties are part of the model, those turned off are not.
|
||||
pub fn load_with_profiles(root: &Path, profiles: &[String]) -> MavenModel {
|
||||
load_with(root, default_local_repo(), profiles)
|
||||
}
|
||||
|
||||
pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo);
|
||||
load_with(root, local_repo, &[])
|
||||
}
|
||||
|
||||
/// The `-P` entries' choice for profile `id`: on, off (`!id` / `-id`), or none (the last wins).
|
||||
fn profile_choice(chosen: &[String], id: &str) -> Option<bool> {
|
||||
chosen.iter().rev().find_map(|c| {
|
||||
let c = c.trim();
|
||||
if c == id {
|
||||
Some(true)
|
||||
} else if c.strip_prefix('!').or_else(|| c.strip_prefix('-')) == Some(id) {
|
||||
Some(false)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn load_with(root: &Path, local_repo: Option<PathBuf>, profiles: &[String]) -> MavenModel {
|
||||
let mut resolver = Resolver::new(local_repo, profiles);
|
||||
let mut model = MavenModel::default();
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
// (pom, aggregated)
|
||||
@@ -1076,7 +1110,7 @@ pub(crate) fn load_with_repo(root: &Path, local_repo: Option<PathBuf>) -> MavenM
|
||||
let i = |s: &str| interpolate(s, &props);
|
||||
|
||||
let mut module_names: Vec<String> = pom.modules.clone();
|
||||
for p in pom.profiles.iter().filter(|p| p.active_by_default) {
|
||||
for p in resolver.active(&pom.profiles) {
|
||||
module_names.extend(p.modules.iter().cloned());
|
||||
}
|
||||
let mut modules: Vec<PathBuf> = Vec::new();
|
||||
@@ -1699,6 +1733,52 @@ mod tests {
|
||||
assert!(model.projects.iter().all(|p| p.command == if cfg!(windows) { "mvn.cmd" } else { "mvn" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chosen_profiles_bring_their_modules_and_dependencies() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
root,
|
||||
"pom.xml",
|
||||
r#"<project><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version><packaging>pom</packaging>
|
||||
<modules><module>core</module></modules>
|
||||
<profiles>
|
||||
<profile><id>flink-1.17</id><activation><activeByDefault>true</activeByDefault></activation>
|
||||
<modules><module>flink-1.17</module></modules></profile>
|
||||
<profile><id>flink-1.20</id><modules><module>flink-1.20</module></modules>
|
||||
<dependencies><dependency><groupId>org.apache.flink</groupId><artifactId>flink-core</artifactId><version>1.20.0</version></dependency></dependencies>
|
||||
</profile>
|
||||
</profiles>
|
||||
</project>"#,
|
||||
);
|
||||
for m in ["core", "flink-1.17", "flink-1.20"] {
|
||||
write(
|
||||
root,
|
||||
&format!("{m}/pom.xml"),
|
||||
&format!(
|
||||
"<project><parent><groupId>demo</groupId><artifactId>parent</artifactId><version>1.0</version></parent><artifactId>{m}</artifactId></project>"
|
||||
),
|
||||
);
|
||||
}
|
||||
let dirs = |m: &MavenModel| m.projects.iter().map(|p| p.dir.clone()).collect::<Vec<_>>();
|
||||
let flink = |m: &MavenModel, dir: &str| {
|
||||
m.projects.iter().find(|p| p.dir == dir).unwrap().dependencies.iter().any(|d| d.artifact_id == "flink-core")
|
||||
};
|
||||
|
||||
let default = load_with(root, None, &[]);
|
||||
assert_eq!(dirs(&default), ["", "core", "flink-1.17"]);
|
||||
assert!(!flink(&default, "core"));
|
||||
|
||||
// Choosing another profile of the POM turns its activeByDefault one off (as `-P` does).
|
||||
let chosen = load_with(root, None, &["flink-1.20".into()]);
|
||||
assert_eq!(dirs(&chosen), ["", "core", "flink-1.20"]);
|
||||
assert!(flink(&chosen, "core"), "a module inherits the chosen profile's dependencies");
|
||||
|
||||
let both = load_with(root, None, &["flink-1.20".into(), "flink-1.17".into()]);
|
||||
assert_eq!(dirs(&both), ["", "core", "flink-1.17", "flink-1.20"]);
|
||||
assert_eq!(dirs(&load_with(root, None, &["!flink-1.17".into()])), ["", "core"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_of_a_parent_pom_are_inherited() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -283,7 +283,7 @@ pub fn sync_maven(
|
||||
cancel: &CancellationToken,
|
||||
progress: &dyn Fn(SyncProgress),
|
||||
) -> IdeResult<()> {
|
||||
let mut model = maven::load(root);
|
||||
let mut model = maven::load_with_profiles(root, &options.profiles);
|
||||
maven::UnlinkedProjects::from_model(&model, &options.maven_settings).filter(&mut model);
|
||||
std::fs::create_dir_all(cache_dir)?;
|
||||
let mut cache = MavenCache {
|
||||
|
||||
@@ -123,12 +123,14 @@ fn taskkill_tree(child: &Child) {
|
||||
/// inherits them loads the image's (older) libraries and plugins next to its own, e.g.
|
||||
/// `curl: symbol lookup error ... nghttp2_...`. The IDE process keeps its environment
|
||||
/// (WebKit and GTK still need it). Then applies the [`set_env_overlay`] variables and
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]).
|
||||
/// Variables the call site has already set are kept. A no-op outside an AppImage
|
||||
/// without an overlay.
|
||||
/// those of a manual HTTP proxy ([`crate::proxy_settings::ProxySettings::child_env`]),
|
||||
/// and the login shell's variables this process lacks ([`shell_env`]: an app started from
|
||||
/// Finder/Dock doesn't have what rc files export, such as `GOPATH`, `JAVA_HOME` or
|
||||
/// `MAVEN_OPTS`). Variables the call site has already set are kept.
|
||||
pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
let fixes = child_env_changes();
|
||||
if fixes.is_empty() {
|
||||
let shell: Vec<_> = shell_env().collect();
|
||||
if fixes.is_empty() && shell.is_empty() {
|
||||
return cmd;
|
||||
}
|
||||
let explicit: Vec<OsString> = cmd.get_envs().map(|(k, _)| k.to_owned()).collect();
|
||||
@@ -138,6 +140,9 @@ pub fn host_env(cmd: &mut Command) -> &mut Command {
|
||||
None => cmd.env_remove(key),
|
||||
};
|
||||
}
|
||||
for (key, value) in shell.into_iter().filter(|(k, _)| !explicit.contains(k)) {
|
||||
cmd.env(key, value);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
@@ -154,12 +159,13 @@ pub fn host_vars() -> impl Iterator<Item = (OsString, OsString)> {
|
||||
apply_changes(std::env::vars_os().collect(), &child_env_changes()).into_iter()
|
||||
}
|
||||
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]).
|
||||
/// [`std::env::var_os`] as a child should see it (see [`host_env`]), the login shell's value
|
||||
/// included when this process lacks the variable.
|
||||
pub fn host_var_os(key: impl AsRef<OsStr>) -> Option<OsString> {
|
||||
let key = key.as_ref();
|
||||
match child_env_changes().into_iter().find(|(k, _)| k == key) {
|
||||
Some((_, fixed)) => fixed,
|
||||
None => std::env::var_os(key),
|
||||
None => std::env::var_os(key).or_else(|| shell_env().find(|(k, _)| k == key).map(|(_, v)| v)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -572,6 +578,15 @@ mod tests {
|
||||
assert_eq!(get("HOME"), Some(None));
|
||||
let shell: Vec<_> = shell_env().map(|(k, _)| k).collect();
|
||||
assert_eq!(shell, [OsString::from("OPENIDEA_OVERLAY_B")]);
|
||||
// Every child gets what the login shell exports and the IDE process lacks (a Finder
|
||||
// launch: `GOPATH`, `JAVA_HOME`...), unless the call site sets it.
|
||||
assert_eq!(get("OPENIDEA_OVERLAY_B"), Some(Some("b".into())));
|
||||
let mut own = Command::new("true");
|
||||
own.env("OPENIDEA_OVERLAY_B", "call site");
|
||||
host_env(&mut own);
|
||||
let value = own.get_envs().find(|(n, _)| *n == "OPENIDEA_OVERLAY_B").and_then(|(_, v)| v);
|
||||
assert_eq!(value, Some(OsStr::new("call site")));
|
||||
assert_eq!(host_var_os("OPENIDEA_OVERLAY_B"), Some("b".into()));
|
||||
assert_eq!(host_var_os("PATH"), Some("/reloaded".into()));
|
||||
assert!(host_vars().all(|(k, _)| k != "HOME"));
|
||||
assert!(host_vars().any(|(k, v)| k == "OPENIDEA_OVERLAY_A" && v == "project"));
|
||||
|
||||
@@ -157,6 +157,16 @@ pub struct LanguageServerSettings {
|
||||
pub languages: Vec<String>,
|
||||
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
|
||||
pub root_markers: Vec<String>,
|
||||
/// Replaces the definition's `features`: what the server is used for
|
||||
/// (`code-action`, `hover`, …); an empty list means everything.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub features: Option<Vec<String>>,
|
||||
/// Replaces the definition's `code-action-kinds` (`refactor`); an empty
|
||||
/// list keeps every kind.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub code_action_kinds: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl Default for LanguageServerSettings {
|
||||
@@ -170,6 +180,8 @@ impl Default for LanguageServerSettings {
|
||||
settings: None,
|
||||
languages: Vec::new(),
|
||||
root_markers: Vec::new(),
|
||||
features: None,
|
||||
code_action_kinds: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,6 +351,7 @@ pub struct AppearanceSettings {
|
||||
pub preferred_dark_theme: String,
|
||||
/// A plugin's file icon theme (`<plugin-id>/<theme-id>`); empty = the built-in icons.
|
||||
pub file_icon_theme: String,
|
||||
pub file_icon_theme_options: BTreeMap<String, FileIconThemeOptions>,
|
||||
/// IDE zoom in percent (50–300).
|
||||
pub zoom: u32,
|
||||
/// Apply `ui_font_family` / `ui_font_size` (otherwise the theme's).
|
||||
@@ -374,6 +387,24 @@ pub struct AppearanceSettings {
|
||||
/// Main toolbar entries per side, in order: title widget ids (`project`,
|
||||
/// `vcs.branch`…) or `action:<id>`. Widgets in neither list nor `hidden`
|
||||
/// (registered later) keep their default place.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FileIconThemeOptions {
|
||||
pub groups: BTreeMap<String, bool>,
|
||||
pub choices: BTreeMap<String, String>,
|
||||
pub hide_files: bool,
|
||||
pub hide_folders: bool,
|
||||
pub hollow_folders: bool,
|
||||
pub hide_ui_icons: bool,
|
||||
pub hide_psi_icons: bool,
|
||||
pub monochrome: Option<String>,
|
||||
pub saturation: Option<u8>,
|
||||
pub opacity: Option<u8>,
|
||||
pub icon_size: Option<u8>,
|
||||
pub row_height: Option<u8>,
|
||||
pub accent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct MainToolbarSettings {
|
||||
@@ -394,6 +425,7 @@ impl Default for AppearanceSettings {
|
||||
preferred_light_theme: "light".into(),
|
||||
preferred_dark_theme: "dark".into(),
|
||||
file_icon_theme: String::new(),
|
||||
file_icon_theme_options: BTreeMap::new(),
|
||||
zoom: 100,
|
||||
use_custom_font: false,
|
||||
ui_font_family: "Inter".into(),
|
||||
@@ -919,6 +951,36 @@ mod tests {
|
||||
assert_eq!(serde_json::from_value::<Settings>(value).unwrap(), settings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_icon_theme_options_default_empty_and_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"fileIconTheme":"p/t"}}"#).unwrap();
|
||||
assert!(old.appearance.file_icon_theme_options.is_empty());
|
||||
let json = serde_json::json!({"appearance":{"fileIconThemeOptions":{"p/t":{
|
||||
"groups":{"tests":true},"choices":{"arrows":"plusminus"},"hideFiles":true,"hideFolders":false,"hollowFolders":true,"hideUiIcons":true,"hidePsiIcons":false,
|
||||
"monochrome":"#546e7a","saturation":5,"opacity":80,"iconSize":20,"rowHeight":30,"accent":"#009688"}}}});
|
||||
let settings: Settings = serde_json::from_value(json.clone()).unwrap();
|
||||
let options = &settings.appearance.file_icon_theme_options["p/t"];
|
||||
assert!(options.hide_files && options.hollow_folders && !options.hide_folders);
|
||||
assert!(options.hide_ui_icons && !options.hide_psi_icons);
|
||||
assert!(options.groups["tests"]);
|
||||
assert_eq!(options.choices["arrows"], "plusminus");
|
||||
assert_eq!(
|
||||
(options.saturation, options.opacity, options.icon_size, options.row_height),
|
||||
(Some(5), Some(80), Some(20), Some(30))
|
||||
);
|
||||
assert_eq!(options.monochrome.as_deref(), Some("#546e7a"));
|
||||
assert_eq!(options.accent.as_deref(), Some("#009688"));
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
let restored: Settings = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored, settings);
|
||||
let plain = serde_json::to_value(FileIconThemeOptions::default()).unwrap();
|
||||
assert_eq!(plain["hideFiles"], false);
|
||||
assert_eq!(plain["hideUiIcons"], false);
|
||||
assert_eq!(plain["hidePsiIcons"], false);
|
||||
assert_eq!(plain["monochrome"], serde_json::Value::Null);
|
||||
assert_eq!(plain["groups"], serde_json::json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_font_fallback_defaults_empty_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"useCustomFont":true}}"#).unwrap();
|
||||
|
||||
@@ -212,6 +212,19 @@ pub struct Variable {
|
||||
pub evaluate_name: Option<String>,
|
||||
}
|
||||
|
||||
/// A completion the adapter offers for an expression (DAP `completions`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DebugCompletion {
|
||||
pub label: String,
|
||||
/// What to insert, when it differs from `label`.
|
||||
pub text: Option<String>,
|
||||
/// DAP item type: `method`, `field`, `variable`, `class`, `keyword`, …
|
||||
pub kind: Option<String>,
|
||||
pub detail: Option<String>,
|
||||
pub sort_text: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum StepKind {
|
||||
@@ -1157,6 +1170,47 @@ impl DebugManager {
|
||||
})
|
||||
}
|
||||
|
||||
/// Completions for `text` at `line` / `column` (0-based, UTF-16) in the frame's scope; none
|
||||
/// when the adapter doesn't offer them.
|
||||
pub async fn completions(
|
||||
&self,
|
||||
s: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<DebugCompletion>> {
|
||||
let session = self.session(s)?;
|
||||
if !session.supports("supportsCompletionsRequest") {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut args = json!({ "text": text, "line": line + 1, "column": column + 1 });
|
||||
if let Some(f) = frame_id {
|
||||
args["frameId"] = f.into();
|
||||
}
|
||||
let body = session.conn.request_timeout("completions", args, REQUEST_TIMEOUT).await?;
|
||||
let text =
|
||||
|v: &Value, key: &str| v.get(key).and_then(Value::as_str).filter(|t| !t.is_empty()).map(str::to_owned);
|
||||
Ok(body
|
||||
.get("targets")
|
||||
.and_then(Value::as_array)
|
||||
.map(|targets| {
|
||||
targets
|
||||
.iter()
|
||||
.filter_map(|t| {
|
||||
Some(DebugCompletion {
|
||||
label: text(t, "label")?,
|
||||
text: text(t, "text"),
|
||||
kind: text(t, "type"),
|
||||
detail: text(t, "detail"),
|
||||
sort_text: text(t, "sortText"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn resume(&self, s: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
let session = self.session(s)?;
|
||||
let body = session.conn.request_timeout("continue", json!({ "threadId": thread_id }), REQUEST_TIMEOUT).await?;
|
||||
|
||||
@@ -73,6 +73,7 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
msg,
|
||||
json!({
|
||||
"supportsConfigurationDoneRequest": true,
|
||||
"supportsCompletionsRequest": true,
|
||||
"supportsTerminateRequest": true,
|
||||
"supportsSetVariable": true,
|
||||
"supportsFunctionBreakpoints": true,
|
||||
@@ -172,6 +173,14 @@ async fn fake_adapter<S: AsyncRead + AsyncWrite + Unpin + Send + 'static>(stream
|
||||
]})
|
||||
),
|
||||
"evaluate" => ok!(msg, json!({"result": "42", "type": "int", "variablesReference": 0})),
|
||||
"completions" => ok!(
|
||||
msg,
|
||||
json!({"targets": [
|
||||
{"label": "getName()", "text": "getName", "type": "method", "detail": "String"},
|
||||
{"label": "name", "type": "field"},
|
||||
{"label": ""},
|
||||
]})
|
||||
),
|
||||
"continue" => {
|
||||
ok!(msg, json!({"allThreadsContinued": true}));
|
||||
send!(json!({"type": "event", "event": "exited", "body": {"exitCode": 3}}));
|
||||
@@ -368,6 +377,14 @@ async fn full_protocol_flow() {
|
||||
let eval = h.request("evaluate");
|
||||
assert_eq!(eval["arguments"]["context"], "watch");
|
||||
assert_eq!(eval["arguments"]["frameId"], 10);
|
||||
let items = h.manager.completions(id, Some(10), "req.na".into(), 0, 6).await.unwrap();
|
||||
assert_eq!(
|
||||
items.iter().map(|c| (c.label.as_str(), c.text.as_deref(), c.kind.as_deref())).collect::<Vec<_>>(),
|
||||
[("getName()", Some("getName"), Some("method")), ("name", None, Some("field"))]
|
||||
);
|
||||
let asked = h.request("completions");
|
||||
assert_eq!((asked["arguments"]["line"].clone(), asked["arguments"]["column"].clone()), (json!(1), json!(7)));
|
||||
assert_eq!(asked["arguments"]["frameId"], 10);
|
||||
|
||||
// Live breakpoint updates reach the session.
|
||||
let states = h.manager.set_breakpoints(path.clone(), vec![bp(0)]).await.unwrap();
|
||||
|
||||
@@ -8,7 +8,7 @@ license.workspace = true
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
serde_json = { workspace = true, features = ["raw_value"] }
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
tokio.workspace = true
|
||||
@@ -21,6 +21,10 @@ bb8.workspace = true
|
||||
tokio-util = { workspace = true, features = ["compat"] }
|
||||
dirs.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-http.workspace = true
|
||||
reqwest = { workspace = true, features = ["query"] }
|
||||
rustls.workspace = true
|
||||
uuid.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -0,0 +1,844 @@
|
||||
use crate::conn::{QueryResult, ResultColumn, Setup};
|
||||
use crate::source::{DataSource, DbSslMode, DbTxMode};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::RwLock;
|
||||
use reqwest::{Client, RequestBuilder, StatusCode, Url};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ClickhousePool(Arc<State>);
|
||||
|
||||
struct State {
|
||||
client: RwLock<Option<Client>>,
|
||||
endpoint: Url,
|
||||
user: String,
|
||||
password: String,
|
||||
database: String,
|
||||
settings: Vec<(String, String)>,
|
||||
read_only: bool,
|
||||
session_id: String,
|
||||
session: Mutex<bool>,
|
||||
closed: CancellationToken,
|
||||
}
|
||||
|
||||
/// The response format: a names line, a types line, then one JSON array per row, so rows past
|
||||
/// the grid's limit are skipped without being parsed.
|
||||
const FORMAT: &str = "JSONCompactEachRowWithNamesAndTypes";
|
||||
|
||||
pub(crate) fn transaction_error() -> IdeError {
|
||||
IdeError::invalid("ClickHouse HTTP does not support SQL transactions; use auto-commit and run writes directly")
|
||||
}
|
||||
|
||||
impl ClickhousePool {
|
||||
pub(crate) async fn connect(source: &DataSource, password: Option<&str>, setup: Setup) -> IdeResult<Self> {
|
||||
if source.options.transaction == DbTxMode::Manual {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
for (name, _) in &setup.params {
|
||||
if reserved_setting(name) {
|
||||
return Err(IdeError::invalid(format!("ClickHouse HTTP manages the {name} setting")));
|
||||
}
|
||||
}
|
||||
let secure = matches!(source.ssl, DbSslMode::Require | DbSslMode::VerifyCa | DbSslMode::VerifyFull);
|
||||
let tls_files = [&source.ssl_ca_file, &source.ssl_cert_file, &source.ssl_key_file];
|
||||
if !secure && tls_files.iter().any(|p| !p.trim().is_empty()) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse TLS certificate files require an SSL mode of Require, Verify CA, or Verify Full",
|
||||
));
|
||||
}
|
||||
let host = source.host.trim();
|
||||
if host.is_empty() || host.contains(['/', '@', '?', '#']) {
|
||||
return Err(IdeError::invalid("ClickHouse host must contain only a hostname or IP address"));
|
||||
}
|
||||
let host = if host.contains(':') && !host.starts_with('[') { format!("[{host}]") } else { host.to_owned() };
|
||||
let scheme = if secure { "https" } else { "http" };
|
||||
let port = source.port.unwrap_or(if secure { 8443 } else { 8123 });
|
||||
let endpoint = Url::parse(&format!("{scheme}://{host}:{port}/"))
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse HTTP host or port"))?;
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let _ = rustls::crypto::ring::default_provider().install_default();
|
||||
let mut builder = Client::builder()
|
||||
.connect_timeout(timeout)
|
||||
.pool_max_idle_per_host(4)
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.user_agent(concat!("OpenIDEA/", env!("CARGO_PKG_VERSION")));
|
||||
let ca = source.ssl_ca_file.trim();
|
||||
if !ca.is_empty() {
|
||||
let pem = read_pem(ca, "CA certificate")?;
|
||||
let certificates = reqwest::Certificate::from_pem_bundle(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse CA certificate PEM"))?;
|
||||
if certificates.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse CA certificate PEM contains no certificates"));
|
||||
}
|
||||
builder = builder.tls_certs_only(certificates);
|
||||
}
|
||||
if source.ssl == DbSslMode::VerifyCa {
|
||||
if ca.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse Verify CA requires a CA certificate file"));
|
||||
}
|
||||
builder = builder.tls_danger_accept_invalid_hostnames(true);
|
||||
}
|
||||
let cert = source.ssl_cert_file.trim();
|
||||
let key = source.ssl_key_file.trim();
|
||||
if cert.is_empty() != key.is_empty() {
|
||||
return Err(IdeError::invalid("ClickHouse client TLS requires both certificate and key files"));
|
||||
}
|
||||
if !cert.is_empty() {
|
||||
let mut pem = read_pem(cert, "client certificate")?;
|
||||
pem.push(b'\n');
|
||||
pem.extend(read_pem(key, "client key")?);
|
||||
let identity = reqwest::Identity::from_pem(&pem)
|
||||
.map_err(|_| IdeError::invalid("invalid ClickHouse client certificate or key PEM"))?;
|
||||
builder = builder.identity(identity);
|
||||
}
|
||||
let proxy = ide_core::proxy_settings::proxy();
|
||||
let client = ide_http::client::with_proxy(builder, &proxy).build().map_err(http_error)?;
|
||||
let database = if source.options.default_schema.trim().is_empty() {
|
||||
source.database.trim()
|
||||
} else {
|
||||
source.options.default_schema.trim()
|
||||
};
|
||||
let pool = Self(Arc::new(State {
|
||||
client: RwLock::new(Some(client)),
|
||||
endpoint,
|
||||
user: if source.user.is_empty() { "default".into() } else { source.user.clone() },
|
||||
password: password.unwrap_or_default().to_owned(),
|
||||
database: if database.is_empty() { "default".into() } else { database.into() },
|
||||
settings: setup.params,
|
||||
read_only: source.read_only,
|
||||
session_id: uuid::Uuid::new_v4().to_string(),
|
||||
session: Mutex::new(false),
|
||||
closed: CancellationToken::new(),
|
||||
}));
|
||||
let cancel = CancellationToken::new();
|
||||
let initialize = async {
|
||||
pool.execute("SELECT 1", 1, &cancel).await?;
|
||||
for sql in setup.statements {
|
||||
pool.execute(&sql, 0, &cancel).await?;
|
||||
}
|
||||
Ok(pool.clone())
|
||||
};
|
||||
tokio::pin!(initialize);
|
||||
tokio::select! {
|
||||
result = &mut initialize => {
|
||||
if result.is_err() {
|
||||
pool.close();
|
||||
}
|
||||
result
|
||||
},
|
||||
() = tokio::time::sleep(timeout) => {
|
||||
cancel.cancel();
|
||||
let _ = initialize.await;
|
||||
pool.close();
|
||||
Err(IdeError::new(ErrorKind::TimedOut, "timed out connecting to ClickHouse HTTP"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close(&self) {
|
||||
self.0.closed.cancel();
|
||||
self.0.client.write().take();
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.0.closed.is_cancelled()
|
||||
}
|
||||
|
||||
fn client(&self) -> IdeResult<Client> {
|
||||
self.0.client.read().clone().ok_or_else(|| IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"))
|
||||
}
|
||||
|
||||
fn request(&self, client: &Client, query_id: &str, max_rows: usize, initialized: bool) -> RequestBuilder {
|
||||
let mut request = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.header(reqwest::header::CONTENT_TYPE, "text/plain; charset=utf-8")
|
||||
.query(&self.0.settings)
|
||||
.query(&[
|
||||
("session_id", self.0.session_id.clone()),
|
||||
("session_timeout", "3600".into()),
|
||||
("query_id", query_id.to_owned()),
|
||||
("default_format", FORMAT.into()),
|
||||
("output_format_json_quote_denormals", "1".into()),
|
||||
("max_result_rows", max_rows.saturating_add(1).to_string()),
|
||||
("result_overflow_mode", "break".into()),
|
||||
]);
|
||||
if initialized {
|
||||
request = request.query(&[("session_check", "1")]);
|
||||
} else {
|
||||
request = request.query(&[("database", &self.0.database)]);
|
||||
}
|
||||
if self.0.read_only {
|
||||
request = request.query(&[("readonly", "1")]);
|
||||
}
|
||||
request
|
||||
}
|
||||
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
if transaction_statement(sql) {
|
||||
return Err(transaction_error());
|
||||
}
|
||||
let mut session = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
() = self.0.closed.cancelled() => return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed")),
|
||||
session = self.0.session.lock() => session,
|
||||
};
|
||||
let client = self.client()?;
|
||||
let query_id = uuid::Uuid::new_v4().to_string();
|
||||
let request = self.request(&client, &query_id, max_rows, *session).body(sql.to_owned());
|
||||
let started = Instant::now();
|
||||
let fetch = async {
|
||||
let mut response = request.send().await.map_err(http_error)?;
|
||||
let execution_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
let status = response.status();
|
||||
let header = |name: &str| response.headers().get(name).and_then(|v| v.to_str().ok()).map(str::to_owned);
|
||||
let exception_code = header("X-ClickHouse-Exception-Code");
|
||||
let format = header("X-ClickHouse-Format");
|
||||
let affected = header("X-ClickHouse-Summary")
|
||||
.and_then(|v| serde_json::from_str::<serde_json::Value>(&v).ok())
|
||||
.and_then(|v| {
|
||||
v.get("written_rows")
|
||||
.and_then(|n| n.as_str().and_then(|n| n.parse::<u64>().ok()).or_else(|| n.as_u64()))
|
||||
});
|
||||
if !status.is_success() || exception_code.as_deref().is_some_and(|code| code != "0") {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
return Err(self.server_error(status, exception_code.as_deref(), &body));
|
||||
}
|
||||
let (columns, rows, has_more) = if format.as_deref().is_some_and(|f| f != FORMAT) {
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if !body.iter().all(u8::is_ascii_whitespace) {
|
||||
return Err(IdeError::invalid(
|
||||
"ClickHouse result grids need the default output format; remove the query's FORMAT clause",
|
||||
));
|
||||
}
|
||||
(Vec::new(), Vec::new(), false)
|
||||
} else {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
while let Some(chunk) = response.chunk().await.map_err(http_error)? {
|
||||
reader.push(&chunk)?;
|
||||
}
|
||||
reader.finish().map_err(|error| self.redact_error(error))?
|
||||
};
|
||||
let affected = if columns.is_empty() { affected.map(|n| n as f64) } else { None };
|
||||
Ok(QueryResult {
|
||||
statement: sql.to_owned(),
|
||||
columns,
|
||||
rows,
|
||||
affected,
|
||||
has_more,
|
||||
elapsed_ms: started.elapsed().as_secs_f64() * 1000.0,
|
||||
execution_ms,
|
||||
})
|
||||
};
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
() = cancel.cancelled() => None,
|
||||
() = self.0.closed.cancelled() => None,
|
||||
result = fetch => Some(result),
|
||||
};
|
||||
match result {
|
||||
Some(result) => {
|
||||
if result.is_ok() {
|
||||
*session = true;
|
||||
}
|
||||
result
|
||||
}
|
||||
None => {
|
||||
let killed = self.kill(&client, &query_id).await;
|
||||
match killed {
|
||||
Ok(()) => Err(IdeError::cancelled()),
|
||||
Err(error) => Err(IdeError::new(
|
||||
ErrorKind::Cancelled,
|
||||
format!("cancelled locally; ClickHouse server cancellation failed: {}", error.message),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn kill(&self, client: &Client, query_id: &str) -> IdeResult<()> {
|
||||
let response = client
|
||||
.post(self.0.endpoint.clone())
|
||||
.basic_auth(&self.0.user, Some(&self.0.password))
|
||||
.timeout(Duration::from_secs(3))
|
||||
.body(format!("KILL QUERY WHERE query_id = '{query_id}' SYNC"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(http_error)?;
|
||||
let status = response.status();
|
||||
let code = response
|
||||
.headers()
|
||||
.get("X-ClickHouse-Exception-Code")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(str::to_owned);
|
||||
let body = response.bytes().await.map_err(http_error)?;
|
||||
if status.is_success() && !code.as_deref().is_some_and(|code| code != "0") && !body.starts_with(b"Code:") {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(self.server_error(status, code.as_deref(), &body))
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_error(&self, mut error: IdeError) -> IdeError {
|
||||
if !self.0.password.is_empty() {
|
||||
error.message = error.message.replace(&self.0.password, "[redacted]");
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
fn server_error(&self, status: StatusCode, code: Option<&str>, body: &[u8]) -> IdeError {
|
||||
let message = String::from_utf8_lossy(body);
|
||||
let message: String = exception_text(&message).chars().take(16_384).collect();
|
||||
let auth = matches!(status, StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN)
|
||||
|| matches!(code, Some("193" | "194" | "195" | "516" | "497"));
|
||||
let detail = if message.is_empty() { status.canonical_reason().unwrap_or("request failed") } else { &message };
|
||||
let detail = if detail.contains("SESSION_NOT_FOUND") || detail.contains("Session not found") {
|
||||
"ClickHouse HTTP session expired; reconnect to restore session settings"
|
||||
} else {
|
||||
detail
|
||||
};
|
||||
self.redact_error(IdeError::new(
|
||||
if auth { ErrorKind::PermissionDenied } else { ErrorKind::InvalidInput },
|
||||
format!("ClickHouse HTTP {}: {detail}", status.as_u16()),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
type DecodedResult = (Vec<ResultColumn>, Vec<Vec<Option<String>>>, bool);
|
||||
|
||||
/// Decodes a streamed [`FORMAT`] body. Keeps the first `max_rows` rows; later rows only set
|
||||
/// `has_more` and are read past unparsed, so a grid limit bounds memory however large the
|
||||
/// block `result_overflow_mode=break` lets the server send. A line that is not a JSON array
|
||||
/// starts the exception ClickHouse writes when a query fails mid-stream.
|
||||
struct RowReader {
|
||||
max_rows: usize,
|
||||
pending: Vec<u8>,
|
||||
names: Option<Vec<String>>,
|
||||
types: Option<Vec<String>>,
|
||||
rows: Vec<Vec<Option<String>>>,
|
||||
more: bool,
|
||||
exception: Option<String>,
|
||||
}
|
||||
|
||||
impl RowReader {
|
||||
fn new(max_rows: usize) -> Self {
|
||||
Self { max_rows, pending: Vec::new(), names: None, types: None, rows: Vec::new(), more: false, exception: None }
|
||||
}
|
||||
|
||||
fn push(&mut self, chunk: &[u8]) -> IdeResult<()> {
|
||||
self.pending.extend_from_slice(chunk);
|
||||
let buffer = std::mem::take(&mut self.pending);
|
||||
let mut start = 0;
|
||||
while let Some(end) = buffer[start..].iter().position(|&b| b == b'\n') {
|
||||
self.line(&buffer[start..start + end])?;
|
||||
start += end + 1;
|
||||
}
|
||||
self.pending = buffer[start..].to_vec();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &[u8]) -> IdeResult<()> {
|
||||
if let Some(exception) = &mut self.exception {
|
||||
if exception.len() < 16_384 {
|
||||
exception.push_str(&String::from_utf8_lossy(line));
|
||||
exception.push('\n');
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
let text = line.trim_ascii();
|
||||
if text.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
if text[0] != b'[' {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
}
|
||||
if self.names.is_some() && self.types.is_some() && self.rows.len() >= self.max_rows {
|
||||
self.more = true;
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(values) = serde_json::from_slice::<Vec<Box<serde_json::value::RawValue>>>(text) else {
|
||||
self.exception = Some(format!("{}\n", String::from_utf8_lossy(line)));
|
||||
return Ok(());
|
||||
};
|
||||
let protocol = |message: String| IdeError::new(ErrorKind::Protocol, message);
|
||||
let header = |values: Vec<Box<serde_json::value::RawValue>>| {
|
||||
values
|
||||
.iter()
|
||||
.map(|v| serde_json::from_str::<String>(v.get()))
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map_err(|e| protocol(format!("invalid ClickHouse result header: {e}")))
|
||||
};
|
||||
let Some(names) = &self.names else {
|
||||
self.names = Some(header(values)?);
|
||||
return Ok(());
|
||||
};
|
||||
if self.types.is_none() {
|
||||
self.types = Some(header(values)?);
|
||||
return Ok(());
|
||||
}
|
||||
if values.len() != names.len() {
|
||||
return Err(protocol("ClickHouse result row width does not match its metadata".into()));
|
||||
}
|
||||
let row = values
|
||||
.into_iter()
|
||||
.map(|value| {
|
||||
let text = value.get();
|
||||
if text == "null" {
|
||||
Ok(None)
|
||||
} else if text.starts_with('"') {
|
||||
serde_json::from_str::<String>(text).map(Some).map_err(|e| protocol(e.to_string()))
|
||||
} else {
|
||||
Ok(Some(text.to_owned()))
|
||||
}
|
||||
})
|
||||
.collect::<IdeResult<_>>()?;
|
||||
self.rows.push(row);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn finish(mut self) -> IdeResult<DecodedResult> {
|
||||
let rest = std::mem::take(&mut self.pending);
|
||||
self.line(&rest)?;
|
||||
if let Some(exception) = self.exception {
|
||||
return Err(IdeError::invalid(format!("ClickHouse: {}", exception_text(&exception))));
|
||||
}
|
||||
let Some(names) = self.names else { return Ok((Vec::new(), Vec::new(), false)) };
|
||||
let types = self.types.unwrap_or_default();
|
||||
if types.len() != names.len() {
|
||||
return Err(IdeError::new(ErrorKind::Protocol, "ClickHouse result types do not match its columns"));
|
||||
}
|
||||
let columns = names.into_iter().zip(types).map(|(name, type_name)| ResultColumn { name, type_name }).collect();
|
||||
Ok((columns, self.rows, self.more))
|
||||
}
|
||||
}
|
||||
|
||||
/// The server's message in a failed response: rows already streamed and the
|
||||
/// `__exception__` block markers ClickHouse wraps a mid-stream error in are dropped.
|
||||
fn exception_text(body: &str) -> &str {
|
||||
let Some(at) = body.find("Code:") else { return body.trim() };
|
||||
let text = &body[at..];
|
||||
match text.find("\n__exception__") {
|
||||
// The line before the closing marker is `<length> <tag>`.
|
||||
Some(end) => text[..end].trim_end().rsplit_once('\n').map_or(text[..end].trim(), |(message, _)| message.trim()),
|
||||
None => text.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
fn http_error(error: reqwest::Error) -> IdeError {
|
||||
use std::error::Error;
|
||||
let kind = if error.is_timeout() { ErrorKind::TimedOut } else { ErrorKind::Io };
|
||||
let error = error.without_url();
|
||||
let mut message = error.to_string();
|
||||
let mut source = error.source();
|
||||
while let Some(error) = source {
|
||||
let detail = error.to_string();
|
||||
if !message.contains(&detail) {
|
||||
message.push_str(": ");
|
||||
message.push_str(&detail);
|
||||
}
|
||||
source = error.source();
|
||||
}
|
||||
IdeError::new(kind, format!("ClickHouse HTTP: {message}"))
|
||||
}
|
||||
|
||||
fn read_pem(path: &str, label: &str) -> IdeResult<Vec<u8>> {
|
||||
std::fs::read(ide_ssh::expand(path))
|
||||
.map_err(|error| IdeError::new(ErrorKind::Io, format!("cannot read ClickHouse {label}: {error}")))
|
||||
}
|
||||
|
||||
fn reserved_setting(name: &str) -> bool {
|
||||
let name = name.to_ascii_lowercase();
|
||||
name.starts_with("output_format_json_")
|
||||
|| matches!(
|
||||
name.as_str(),
|
||||
"user"
|
||||
| "password"
|
||||
| "query"
|
||||
| "database"
|
||||
| "session_id"
|
||||
| "session_check"
|
||||
| "session_timeout"
|
||||
| "query_id"
|
||||
| "default_format"
|
||||
| "max_result_rows"
|
||||
| "result_overflow_mode"
|
||||
| "readonly"
|
||||
)
|
||||
}
|
||||
|
||||
fn transaction_statement(sql: &str) -> bool {
|
||||
let mut sql = sql.trim_start();
|
||||
loop {
|
||||
if let Some(rest) = sql.strip_prefix("--").or_else(|| sql.strip_prefix('#')) {
|
||||
sql = rest.split_once('\n').map_or("", |(_, rest)| rest).trim_start();
|
||||
} else if let Some(rest) = sql.strip_prefix("/*") {
|
||||
sql = rest.split_once("*/").map_or("", |(_, rest)| rest).trim_start();
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let mut words = sql.split(|c: char| !c.is_ascii_alphanumeric() && c != '_').filter(|s| !s.is_empty());
|
||||
let first = words.next().unwrap_or_default();
|
||||
["BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"].iter().any(|s| first.eq_ignore_ascii_case(s))
|
||||
|| (first.eq_ignore_ascii_case("START") && words.next().is_some_and(|s| s.eq_ignore_ascii_case("TRANSACTION")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::conn::Pool;
|
||||
use crate::source::{DbDriver, DbOptions, DbProperty};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
const ONE: &str = "[\"1\"]\n[\"UInt8\"]\n[1]\n";
|
||||
|
||||
async fn read_request(stream: &mut tokio::net::TcpStream) -> String {
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0; 4096];
|
||||
loop {
|
||||
let count = tokio::time::timeout(Duration::from_secs(5), stream.read(&mut buffer)).await.unwrap().unwrap();
|
||||
assert!(count > 0, "request ended early");
|
||||
bytes.extend_from_slice(&buffer[..count]);
|
||||
if let Some(end) = bytes.windows(4).position(|s| s == b"\r\n\r\n") {
|
||||
let header = String::from_utf8_lossy(&bytes[..end]);
|
||||
let length: usize = header
|
||||
.lines()
|
||||
.find_map(|line| {
|
||||
line.to_ascii_lowercase().strip_prefix("content-length:").map(|v| v.trim().parse().unwrap())
|
||||
})
|
||||
.unwrap_or(0);
|
||||
if bytes.len() >= end + 4 + length {
|
||||
return String::from_utf8(bytes).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn reply(stream: &mut tokio::net::TcpStream, status: &str, headers: &str, body: &str) {
|
||||
let response =
|
||||
format!("HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n{headers}\r\n{body}", body.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
|
||||
async fn server(
|
||||
responses: Vec<(&'static str, &'static str, &'static str)>,
|
||||
) -> (u16, tokio::task::JoinHandle<Vec<String>>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let mut requests = Vec::new();
|
||||
for (status, headers, body) in responses {
|
||||
let (mut stream, _) =
|
||||
tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
requests.push(read_request(&mut stream).await);
|
||||
reply(&mut stream, status, headers, body).await;
|
||||
}
|
||||
requests
|
||||
});
|
||||
(port, task)
|
||||
}
|
||||
|
||||
fn source(port: u16) -> DataSource {
|
||||
DataSource { driver: DbDriver::Clickhouse, host: "127.0.0.1".into(), port: Some(port), ..Default::default() }
|
||||
}
|
||||
|
||||
fn decode(body: &[u8], max_rows: usize, chunk: usize) -> IdeResult<DecodedResult> {
|
||||
let mut reader = RowReader::new(max_rows);
|
||||
for part in body.chunks(chunk) {
|
||||
reader.push(part)?;
|
||||
}
|
||||
reader.finish()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_preserve_precision_nulls_and_strings_across_chunks() {
|
||||
let body = concat!(
|
||||
r#"["n", "d", "v", "v", "v"]"#,
|
||||
"\n",
|
||||
r#"["UInt64", "Decimal(38, 9)", "Nullable(String)", "Nullable(String)", "Nullable(String)"]"#,
|
||||
"\n",
|
||||
r#"[18446744073709551615, 12345678901234567890.123456789, null, "null", "ᴺᵁᴸᴸ"]"#,
|
||||
);
|
||||
for chunk in [1, 7, body.len()] {
|
||||
let (columns, rows, more) = decode(body.as_bytes(), 1, chunk).unwrap();
|
||||
assert_eq!(columns.len(), 5);
|
||||
assert_eq!(columns[1].type_name, "Decimal(38, 9)");
|
||||
assert_eq!(
|
||||
rows[0],
|
||||
vec![
|
||||
Some("18446744073709551615".into()),
|
||||
Some("12345678901234567890.123456789".into()),
|
||||
None,
|
||||
Some("null".into()),
|
||||
Some("ᴺᵁᴸᴸ".into()),
|
||||
]
|
||||
);
|
||||
assert!(!more);
|
||||
}
|
||||
let (columns, rows, more) = decode(b"[\"x\"]\n[\"String\"]\n", 0, 64).unwrap();
|
||||
assert_eq!(columns[0].name, "x");
|
||||
assert!(rows.is_empty() && !more);
|
||||
assert_eq!(decode(b"", 5, 64).unwrap().0.len(), 0);
|
||||
assert!(decode(b"[\"x\"]\n[\"UInt8\"]\n[1, 2]\n", 1, 64).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_past_the_limit_are_skipped_but_a_later_exception_still_fails() {
|
||||
let mut body = String::from("[\"n\"]\n[\"UInt64\"]\n");
|
||||
for n in 0..1000 {
|
||||
body.push_str(&format!("[{n}]\n"));
|
||||
}
|
||||
let (_, rows, more) = decode(body.as_bytes(), 2, 4096).unwrap();
|
||||
assert_eq!(rows, vec![vec![Some("0".into())], vec![Some("1".into())]]);
|
||||
assert!(more);
|
||||
body.push_str("\n__exception__\nabcdefgh\nCode: 395. DB::Exception: Value passed to 'throwIf' is non-zero\n");
|
||||
body.push_str("62 abcdefgh\n__exception__\n");
|
||||
let error = decode(body.as_bytes(), 2, 4096).unwrap_err();
|
||||
assert_eq!(error.message, "ClickHouse: Code: 395. DB::Exception: Value passed to 'throwIf' is non-zero");
|
||||
let multiline = "Code: 62. DB::Exception: Syntax error\nat line 2";
|
||||
assert_eq!(exception_text(multiline), multiline);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn http_requests_use_auth_settings_limits_and_a_shared_session() {
|
||||
let (port, requests) = server(vec![
|
||||
("200 OK", "X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n", ONE),
|
||||
("200 OK", "", ""),
|
||||
(
|
||||
"200 OK",
|
||||
"X-ClickHouse-Format: JSONCompactEachRowWithNamesAndTypes\r\n",
|
||||
"[\"n\"]\n[\"UInt64\"]\n[\"1\"]\n[\"2\"]\n[\"3\"]\n",
|
||||
),
|
||||
])
|
||||
.await;
|
||||
let source = DataSource {
|
||||
user: "reader".into(),
|
||||
read_only: true,
|
||||
options: DbOptions { default_schema: "analytics".into(), time_zone: "UTC".into(), ..Default::default() },
|
||||
properties: vec![DbProperty { name: "max_threads".into(), value: "2".into() }],
|
||||
..source(port)
|
||||
};
|
||||
let pool = Pool::connect(&source, Some("mock-password"), None).await.unwrap();
|
||||
pool.execute("USE other", 0, &CancellationToken::new()).await.unwrap();
|
||||
let result = pool.execute("SELECT number FROM numbers(3)", 1, &CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(result.rows, vec![vec![Some("1".into())]]);
|
||||
assert!(result.has_more && result.affected.is_none());
|
||||
let requests = requests.await.unwrap();
|
||||
let target = |request: &String| {
|
||||
Url::parse(&format!(
|
||||
"http://localhost{}",
|
||||
request.lines().next().unwrap().split_whitespace().nth(1).unwrap()
|
||||
))
|
||||
.unwrap()
|
||||
};
|
||||
let urls: Vec<_> = requests.iter().map(target).collect();
|
||||
let value = |url: &Url, key: &str| url.query_pairs().find(|(k, _)| k == key).map(|(_, v)| v.into_owned());
|
||||
assert_eq!(value(&urls[0], "database").as_deref(), Some("analytics"));
|
||||
assert_eq!(value(&urls[1], "database"), None);
|
||||
assert_eq!(value(&urls[1], "session_check").as_deref(), Some("1"));
|
||||
assert_eq!(value(&urls[0], "session_id"), value(&urls[2], "session_id"));
|
||||
assert_ne!(value(&urls[0], "query_id"), value(&urls[2], "query_id"));
|
||||
assert_eq!(value(&urls[2], "max_result_rows").as_deref(), Some("2"));
|
||||
for (request, url) in requests.iter().zip(&urls) {
|
||||
assert!(request.starts_with("POST /?"));
|
||||
assert!(request.contains("Basic cmVhZGVyOm1vY2stcGFzc3dvcmQ="));
|
||||
assert!(!url.as_str().contains("reader") && !url.as_str().contains("mock-password"));
|
||||
assert_eq!(value(url, "readonly").as_deref(), Some("1"));
|
||||
assert_eq!(value(url, "session_timezone").as_deref(), Some("UTC"));
|
||||
assert_eq!(value(url, "max_threads").as_deref(), Some("2"));
|
||||
}
|
||||
assert!(requests[0].ends_with("SELECT 1"));
|
||||
assert!(pool.transaction(&[]).await.is_err());
|
||||
let cancel = CancellationToken::new();
|
||||
cancel.cancel();
|
||||
assert_eq!(pool.execute("SELECT 1", 1, &cancel).await.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
assert!(pool.execute("SELECT 1", 1, &CancellationToken::new()).await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sequential_queries_reuse_the_http_connection() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let task = tokio::spawn(async move {
|
||||
let (mut stream, _) = listener.accept().await.unwrap();
|
||||
for _ in 0..2 {
|
||||
read_request(&mut stream).await;
|
||||
let response = format!("HTTP/1.1 200 OK\r\nContent-Length: {}\r\n\r\n{ONE}", ONE.len());
|
||||
stream.write_all(response.as_bytes()).await.unwrap();
|
||||
}
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
assert_eq!(
|
||||
pool.execute("SELECT 1", 1, &CancellationToken::new()).await.unwrap().rows,
|
||||
vec![vec![Some("1".into())]]
|
||||
);
|
||||
task.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_errors_keep_details_and_hide_passwords() {
|
||||
let (port, requests) = server(vec![(
|
||||
"403 Forbidden",
|
||||
"X-ClickHouse-Exception-Code: 516\r\n",
|
||||
"Code: 516. DB::Exception: bad mock-password",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), Some("mock-password"), None).await {
|
||||
Ok(_) => panic!("expected login failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::PermissionDenied);
|
||||
assert!(error.message.contains("516") && error.message.contains("[redacted]"));
|
||||
assert!(!error.message.contains("mock-password"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![("200 OK", "", "Code: 241. DB::Exception: memory limit")]).await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected late query failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error.message.contains("memory limit"));
|
||||
requests.await.unwrap();
|
||||
let (port, requests) = server(vec![(
|
||||
"500 Internal Server Error",
|
||||
"X-ClickHouse-Exception-Code: 395\r\n",
|
||||
"[\"1\"]\n[\"UInt8\"]\n[1]\n\n__exception__\ntag\nCode: 395. DB::Exception: thrown\n32 tag\n__exception__\n",
|
||||
)])
|
||||
.await;
|
||||
let error = match Pool::connect(&source(port), None, None).await {
|
||||
Ok(_) => panic!("expected mid-stream failure"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.message, "ClickHouse HTTP 500: Code: 395. DB::Exception: thrown");
|
||||
requests.await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cancellation_sends_kill_outside_the_busy_session() {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let (started, received) = tokio::sync::oneshot::channel();
|
||||
let server = tokio::spawn(async move {
|
||||
let (mut health, _) = listener.accept().await.unwrap();
|
||||
read_request(&mut health).await;
|
||||
reply(&mut health, "200 OK", "", ONE).await;
|
||||
drop(health);
|
||||
let (mut query, _) = listener.accept().await.unwrap();
|
||||
let request = read_request(&mut query).await;
|
||||
started.send(()).unwrap();
|
||||
let (mut kill, _) = tokio::time::timeout(Duration::from_secs(5), listener.accept()).await.unwrap().unwrap();
|
||||
let kill_request = read_request(&mut kill).await;
|
||||
reply(&mut kill, "200 OK", "", "").await;
|
||||
(request, kill_request)
|
||||
});
|
||||
let pool = Pool::connect(&source(port), None, None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let query = pool.execute("SELECT sleep(30)", 1, &cancel);
|
||||
let cancelled = async {
|
||||
received.await.unwrap();
|
||||
cancel.cancel();
|
||||
};
|
||||
let (result, ()) = tokio::join!(query, cancelled);
|
||||
assert_eq!(result.unwrap_err().kind, ErrorKind::Cancelled);
|
||||
let (query, kill) = server.await.unwrap();
|
||||
let target = query.lines().next().unwrap().split_whitespace().nth(1).unwrap();
|
||||
let url = Url::parse(&format!("http://localhost{target}")).unwrap();
|
||||
let query_id = url.query_pairs().find(|(k, _)| k == "query_id").unwrap().1.into_owned();
|
||||
assert!(kill.ends_with(&format!("KILL QUERY WHERE query_id = '{query_id}' SYNC")));
|
||||
assert!(!kill.contains("session_id="));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unsupported_settings_and_transactions_fail_before_connecting() {
|
||||
for statement in ["BEGIN", "-- comment\n COMMIT", "/* comment */ ROLLBACK", "START TRANSACTION", "SAVEPOINT s"]
|
||||
{
|
||||
assert!(transaction_statement(statement));
|
||||
}
|
||||
assert!(!transaction_statement("SELECT 'BEGIN'"));
|
||||
let sources = [
|
||||
DataSource { options: DbOptions { transaction: DbTxMode::Manual, ..Default::default() }, ..source(1) },
|
||||
DataSource {
|
||||
properties: vec![DbProperty { name: "statement_cache_capacity".into(), value: "4".into() }],
|
||||
..source(1)
|
||||
},
|
||||
DataSource { properties: vec![DbProperty { name: "password".into(), value: "x".into() }], ..source(1) },
|
||||
DataSource { ssl_ca_file: "unused.pem".into(), ..source(1) },
|
||||
DataSource { ssl: DbSslMode::VerifyCa, ..source(1) },
|
||||
DataSource { ssl: DbSslMode::Require, ssl_cert_file: "unused.pem".into(), ..source(1) },
|
||||
];
|
||||
for source in sources {
|
||||
let error = match Pool::connect(&source, None, None).await {
|
||||
Ok(_) => panic!("unsupported settings were accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert_eq!(error.kind, ErrorKind::InvalidInput);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_transport() {
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(std::env::var("CLICKHOUSE_TEST_PORT").ok().and_then(|v| v.parse().ok()).unwrap_or(8443)),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("CLICKHOUSE_TEST_USER"),
|
||||
ssl: DbSslMode::Require,
|
||||
options: DbOptions {
|
||||
time_zone: "UTC".into(),
|
||||
startup_script: "SET max_threads = 2".into(),
|
||||
..Default::default()
|
||||
},
|
||||
properties: vec![DbProperty { name: "max_block_size".into(), value: "50".into() }],
|
||||
..Default::default()
|
||||
};
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("CLICKHOUSE_TEST_PASSWORD");
|
||||
let pool = Pool::connect(&source, Some(&password), None).await.unwrap();
|
||||
let cancel = CancellationToken::new();
|
||||
let settings = pool
|
||||
.execute("SELECT getSetting('max_threads'), getSetting('max_block_size'), timezone()", 1, &cancel)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings.rows, vec![vec![Some("2".into()), Some("50".into()), Some("UTC".into())]]);
|
||||
let result = pool.execute("SELECT toUInt64('18446744073709551615') AS n, CAST(NULL AS Nullable(String)) AS missing, CAST('ᴺᵁᴸᴸ' AS Nullable(String)) AS text, 'a\\nb' AS lines", 1, &cancel).await.unwrap();
|
||||
assert_eq!(
|
||||
result.rows,
|
||||
vec![vec![Some("18446744073709551615".into()), None, Some("ᴺᵁᴸᴸ".into()), Some("a\nb".into())]]
|
||||
);
|
||||
assert_eq!(result.columns[1].type_name, "Nullable(String)");
|
||||
let limited = pool.execute("SELECT number FROM numbers(100)", 2, &cancel).await.unwrap();
|
||||
assert_eq!(limited.rows.len(), 2);
|
||||
assert!(limited.has_more);
|
||||
let marker = format!("transport_cancel_{}", uuid::Uuid::new_v4().simple());
|
||||
let sql = format!("SELECT sleepEachRow(0.05) FROM numbers(50) /* {marker} */");
|
||||
let stop = CancellationToken::new();
|
||||
let query = pool.execute(&sql, 1, &stop);
|
||||
let cancel_query = async {
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
stop.cancel();
|
||||
};
|
||||
let (cancelled, ()) = tokio::join!(query, cancel_query);
|
||||
let cancelled = cancelled.unwrap_err();
|
||||
assert_eq!(cancelled.kind, ErrorKind::Cancelled);
|
||||
assert_eq!(cancelled.message, "cancelled");
|
||||
let active = pool.execute(&format!("SELECT count() FROM system.processes WHERE position(query, '{marker}') > 0 AND query_id != currentQueryID()"), 1, &cancel).await.unwrap();
|
||||
assert_eq!(active.rows, vec![vec![Some("0".into())]]);
|
||||
let table = format!("transport_test_{}", uuid::Uuid::new_v4().simple());
|
||||
pool.execute(&format!("CREATE TEMPORARY TABLE {table} (n UInt8)"), 0, &cancel).await.unwrap();
|
||||
let continuity = async {
|
||||
pool.execute(&format!("INSERT INTO {table} VALUES (7)"), 0, &cancel).await?;
|
||||
pool.execute(&format!("SELECT n FROM {table}"), 1, &cancel).await
|
||||
}
|
||||
.await;
|
||||
let cleanup = pool.execute(&format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
assert_eq!(continuity.unwrap().rows, vec![vec![Some("7".into())]]);
|
||||
cleanup.unwrap();
|
||||
pool.close().await;
|
||||
assert!(pool.is_closed());
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,7 @@ pub enum Pool {
|
||||
Postgres(PgPool),
|
||||
Mysql(MySqlPool),
|
||||
SqlServer(Bb8Pool<MssqlConnectionManager>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
}
|
||||
|
||||
/// Runs `$body` with `$p` bound to the concrete pool of each driver.
|
||||
@@ -115,7 +116,7 @@ macro_rules! with_pool {
|
||||
Pool::Sqlite($p) => $body,
|
||||
Pool::Postgres($p) => $body,
|
||||
Pool::Mysql($p) => $body,
|
||||
Pool::SqlServer(_) => unreachable!(),
|
||||
Pool::SqlServer(_) | Pool::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -503,6 +504,9 @@ impl Setup {
|
||||
value.parse::<u64>().map_err(|_| IdeError::invalid(format!("{what} must be a whole number: {value}")))
|
||||
};
|
||||
match (source.driver, name.to_ascii_lowercase().as_str()) {
|
||||
(DbDriver::Clickhouse, "statement_cache_capacity") => {
|
||||
return Err(IdeError::invalid("ClickHouse HTTP does not support statement caching"));
|
||||
}
|
||||
(_, "statement_cache_capacity") => s.statement_cache = Some(number(&name)? as usize),
|
||||
(_, "connect_timeout") => s.connect_timeout = Some(Duration::from_secs(number(&name)?)),
|
||||
(DbDriver::Postgres, "application_name") => s.application_name = Some(value.to_owned()),
|
||||
@@ -512,6 +516,9 @@ impl Setup {
|
||||
_ => s.params.push((name, value.to_owned())),
|
||||
}
|
||||
}
|
||||
if source.driver == DbDriver::Clickhouse && !o.time_zone.trim().is_empty() {
|
||||
s.params.push(("session_timezone".into(), o.time_zone.trim().into()));
|
||||
}
|
||||
let script = o.startup_script.trim();
|
||||
if !script.is_empty() {
|
||||
s.statements.extend(crate::split::split(script, source.driver).into_iter().map(|st| st.text));
|
||||
@@ -527,6 +534,7 @@ impl Pool {
|
||||
Pool::Postgres(_) => DbDriver::Postgres,
|
||||
Pool::Mysql(_) => DbDriver::Mysql,
|
||||
Pool::SqlServer(_) => DbDriver::SqlServer,
|
||||
Pool::Clickhouse(_) => DbDriver::Clickhouse,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,6 +542,9 @@ impl Pool {
|
||||
pub async fn connect(source: &DataSource, password: Option<&str>, root: Option<&Path>) -> IdeResult<Self> {
|
||||
const MAX: u32 = 4;
|
||||
let setup = Setup::of(source)?;
|
||||
if source.driver == DbDriver::Clickhouse {
|
||||
return crate::clickhouse::ClickhousePool::connect(source, password, setup).await.map(Pool::Clickhouse);
|
||||
}
|
||||
let timeout = setup.connect_timeout.unwrap_or(Duration::from_secs(30));
|
||||
let init = Arc::new(setup.statements);
|
||||
// Runs the session statements (startup script…) on every new connection.
|
||||
@@ -695,12 +706,14 @@ impl Pool {
|
||||
conn.query("SELECT 1", &[]).await.map_err(mssql_err)?.into_results().await.map_err(mssql_err)?;
|
||||
Ok(Pool::SqlServer(pool))
|
||||
}
|
||||
DbDriver::Clickhouse => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
match self {
|
||||
Pool::SqlServer(_) => {}
|
||||
Pool::Clickhouse(p) => p.close(),
|
||||
_ => with_pool!(self, p => p.close().await),
|
||||
}
|
||||
}
|
||||
@@ -708,12 +721,16 @@ impl Pool {
|
||||
pub fn is_closed(&self) -> bool {
|
||||
match self {
|
||||
Pool::SqlServer(_) => false,
|
||||
Pool::Clickhouse(p) => p.is_closed(),
|
||||
_ => with_pool!(self, p => p.is_closed()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `sql` (no row limit beyond `max`), for introspection: cells are text (see [`Cells::Text`]).
|
||||
pub async fn rows(&self, sql: &str, max: usize) -> IdeResult<Vec<Vec<Option<String>>>> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return Ok(p.execute(sql, max, &CancellationToken::new()).await?.rows);
|
||||
}
|
||||
let fetched: IdeResult<Fetched> = match self {
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
@@ -727,6 +744,9 @@ impl Pool {
|
||||
/// Runs one statement and keeps at most `max_rows` rows. Cancelling stops
|
||||
/// the statement on the server (PostgreSQL, MySQL) and drops the connection.
|
||||
pub async fn execute(&self, sql: &str, max_rows: usize, cancel: &CancellationToken) -> IdeResult<QueryResult> {
|
||||
if let Pool::Clickhouse(p) = self {
|
||||
return p.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let mut conn = Held::acquire(self).await?;
|
||||
let r = conn.execute(self, sql, max_rows, cancel).await;
|
||||
if cancel.is_cancelled() {
|
||||
@@ -740,6 +760,7 @@ impl Pool {
|
||||
/// row when flagged (table editor submits).
|
||||
pub async fn transaction(&self, statements: &[(String, bool)]) -> IdeResult<u64> {
|
||||
match self {
|
||||
Pool::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
Pool::SqlServer(p) => {
|
||||
let mut conn = p.get_owned().await.map_err(mssql_pool_err)?;
|
||||
let result = async {
|
||||
@@ -790,6 +811,7 @@ pub enum Held {
|
||||
Postgres(PoolConnection<Postgres>),
|
||||
Mysql(PoolConnection<MySql>),
|
||||
SqlServer(Box<bb8::PooledConnection<'static, MssqlConnectionManager>>),
|
||||
Clickhouse(crate::clickhouse::ClickhousePool),
|
||||
}
|
||||
|
||||
impl Held {
|
||||
@@ -799,6 +821,12 @@ impl Held {
|
||||
Pool::Postgres(p) => Held::Postgres(p.acquire().await.map_err(db_err)?),
|
||||
Pool::Mysql(p) => Held::Mysql(p.acquire().await.map_err(db_err)?),
|
||||
Pool::SqlServer(p) => Held::SqlServer(Box::new(p.get_owned().await.map_err(mssql_pool_err)?)),
|
||||
Pool::Clickhouse(p) => {
|
||||
if p.is_closed() {
|
||||
return Err(IdeError::new(ErrorKind::Io, "ClickHouse connection is closed"));
|
||||
}
|
||||
Held::Clickhouse(p.clone())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -806,13 +834,14 @@ impl Held {
|
||||
pub async fn command(&mut self, sql: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Held::SqlServer(c) => mssql_command(c, sql).await,
|
||||
Held::Clickhouse(_) => Err(crate::clickhouse::transaction_error()),
|
||||
other => {
|
||||
let sql = sqlx::AssertSqlSafe(sql.to_owned());
|
||||
match other {
|
||||
Held::Sqlite(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Postgres(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::Mysql(c) => sqlx::raw_sql(sql).execute(&mut **c).await.map(drop),
|
||||
Held::SqlServer(_) => unreachable!(),
|
||||
Held::SqlServer(_) | Held::Clickhouse(_) => unreachable!(),
|
||||
}
|
||||
.map_err(db_err)
|
||||
}
|
||||
@@ -833,6 +862,7 @@ impl Held {
|
||||
Held::SqlServer(mut c) => {
|
||||
c.0.take();
|
||||
}
|
||||
Held::Clickhouse(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -845,6 +875,9 @@ impl Held {
|
||||
max_rows: usize,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<QueryResult> {
|
||||
if let Held::Clickhouse(c) = self {
|
||||
return c.execute(sql, max_rows, cancel).await;
|
||||
}
|
||||
let started = Instant::now();
|
||||
// `$id`: the query naming the server process, `$kill`: how another connection stops it.
|
||||
macro_rules! run {
|
||||
@@ -887,6 +920,7 @@ impl Held {
|
||||
() = cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
Held::Clickhouse(_) => unreachable!(),
|
||||
};
|
||||
let (columns, rows, affected, has_more, first) = fetched;
|
||||
let elapsed_ms = started.elapsed().as_secs_f64() * 1000.0;
|
||||
@@ -907,7 +941,7 @@ pub fn begin_statement(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Mysql => "START TRANSACTION",
|
||||
DbDriver::SqlServer => "BEGIN TRAN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres => "BEGIN",
|
||||
DbDriver::Sqlite | DbDriver::Postgres | DbDriver::Clickhouse => "BEGIN",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
-12
@@ -2,6 +2,7 @@
|
||||
//! execution ([`conn`]), introspection, table data paging / editing ([`sql`])
|
||||
//! and statement splitting ([`split`]), for SQLite, PostgreSQL and MySQL/MariaDB.
|
||||
|
||||
mod clickhouse;
|
||||
pub mod conn;
|
||||
pub mod history;
|
||||
pub mod plan;
|
||||
@@ -547,12 +548,14 @@ impl DbManager {
|
||||
})?;
|
||||
let version = match source.driver {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => "SELECT version()",
|
||||
DbDriver::Mysql => "SELECT CONCAT(@@version_comment, ' ', VERSION())",
|
||||
DbDriver::SqlServer => "SELECT @@VERSION",
|
||||
};
|
||||
let v = pool.rows(version, 1).await.ok().and_then(|r| r.into_iter().next()?.into_iter().next()?);
|
||||
let result = pool.rows(version, 1).await;
|
||||
pool.close().await;
|
||||
let rows = if source.driver == DbDriver::Clickhouse { result? } else { result.unwrap_or_default() };
|
||||
let v = rows.into_iter().next().and_then(|r| r.into_iter().next().flatten());
|
||||
Ok(v.unwrap_or_else(|| "Connected".into()))
|
||||
}
|
||||
|
||||
@@ -701,8 +704,11 @@ impl DbManager {
|
||||
_ => indexes.push(IndexInfo { name, unique: flag(&unique), columns: vec![col] }),
|
||||
}
|
||||
}
|
||||
let fk_rows =
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default();
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::table_foreign_keys(driver, schema, table), MAX_META_ROWS).await.unwrap_or_default()
|
||||
};
|
||||
let mut foreign_keys = group_foreign_keys(fk_rows, &[]);
|
||||
// SQLite keys that reference the primary key implicitly: look it up.
|
||||
for k in foreign_keys.iter_mut().filter(|k| k.ref_columns.is_empty()) {
|
||||
@@ -715,13 +721,15 @@ impl DbManager {
|
||||
.collect();
|
||||
}
|
||||
foreign_keys.retain(|k| k.columns.len() == k.ref_columns.len());
|
||||
let triggers = pool
|
||||
.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS)
|
||||
.await
|
||||
.unwrap_or_else(|e| {
|
||||
let trigger_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
} else {
|
||||
pool.rows(&sql::triggers(driver, schema, table), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("triggers: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let triggers = trigger_rows
|
||||
.into_iter()
|
||||
.filter_map(|r| {
|
||||
let mut it = r.into_iter();
|
||||
@@ -822,6 +830,11 @@ impl DbManager {
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 1).into_iter().next();
|
||||
ddl.map(|d| format!("{d};\n")).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::Clickhouse => {
|
||||
let q = format!("SHOW CREATE TABLE {}", sql::qualified(driver, schema, name));
|
||||
let ddl = cells(pool.rows(&q, 1).await?, 0).into_iter().next();
|
||||
ddl.map(|d| format!("{};\n", d.trim_end().trim_end_matches(';'))).ok_or_else(missing)
|
||||
}
|
||||
DbDriver::SqlServer => {
|
||||
let obj = format!("{}.{}", schema, name).replace('\'', "''");
|
||||
let ddl = cells(pool.rows(&format!("SELECT OBJECT_DEFINITION(OBJECT_ID('{}'))", obj), 1).await?, 0)
|
||||
@@ -902,10 +915,14 @@ impl DbManager {
|
||||
}
|
||||
}
|
||||
// Foreign keys are a nicety (JOIN completion): a server that refuses the catalog query still completes.
|
||||
let fk_rows = pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
let fk_rows = if driver == DbDriver::Clickhouse {
|
||||
Vec::new()
|
||||
});
|
||||
} else {
|
||||
pool.rows(sql::foreign_keys(driver), MAX_META_ROWS).await.unwrap_or_else(|e| {
|
||||
tracing::warn!("foreign keys: {}", e.message);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
let foreign_keys = group_foreign_keys(fk_rows, &tables);
|
||||
let default_schema = match sql::current_schema(driver) {
|
||||
None => "main".to_owned(),
|
||||
@@ -980,6 +997,9 @@ impl DbManager {
|
||||
let Some(session) = session else {
|
||||
return pool.execute(statement, max_rows, cancel).await;
|
||||
};
|
||||
if pool.driver() == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let mut held = session.lock().await;
|
||||
if held.is_none() {
|
||||
let mut conn = Held::acquire(&pool).await?;
|
||||
@@ -998,8 +1018,11 @@ impl DbManager {
|
||||
|
||||
/// Switches `id` between auto-commit and manual commit. Leaving manual
|
||||
/// mode rolls back what was not committed.
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) {
|
||||
pub async fn set_manual_commit(&self, id: &str, manual: bool) -> IdeResult<()> {
|
||||
if manual {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
self.sessions.lock().entry(id.to_owned()).or_default();
|
||||
} else {
|
||||
let session = self.sessions.lock().remove(id);
|
||||
@@ -1007,10 +1030,14 @@ impl DbManager {
|
||||
end_session(&s).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commits (or rolls back) the open transaction of a manual-commit data source.
|
||||
pub async fn finish_transaction(&self, id: &str, commit: bool) -> IdeResult<()> {
|
||||
if self.source(id).is_ok_and(|s| s.driver == DbDriver::Clickhouse) {
|
||||
return Err(IdeError::invalid("ClickHouse manual transactions are unsupported"));
|
||||
}
|
||||
let session = self.sessions.lock().get(id).cloned();
|
||||
let Some(session) = session else { return Ok(()) };
|
||||
let mut held = session.lock().await;
|
||||
@@ -1052,6 +1079,9 @@ impl DbManager {
|
||||
|
||||
/// Applies the table editor's pending changes in one transaction.
|
||||
pub async fn apply_edits(&self, id: &str, schema: &str, table: &str, edits: &[DbRowEdit]) -> IdeResult<u64> {
|
||||
if self.source(id)?.driver == DbDriver::Clickhouse {
|
||||
return Err(IdeError::invalid("ClickHouse table editing is unsupported"));
|
||||
}
|
||||
let pool = self.pool(id).await?;
|
||||
let driver = pool.driver();
|
||||
let statements = edits
|
||||
@@ -1064,3 +1094,37 @@ impl DbManager {
|
||||
pool.transaction(&statements).await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = Arc::new(SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(1),
|
||||
..Default::default()
|
||||
}])
|
||||
.await
|
||||
.unwrap();
|
||||
let error = db.set_manual_commit("clickhouse", true).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
assert!(db.sessions.lock().is_empty());
|
||||
for commit in [false, true] {
|
||||
let error = db.finish_transaction("clickhouse", commit).await.unwrap_err();
|
||||
assert!(error.message.contains("manual transactions are unsupported"));
|
||||
}
|
||||
let error = db.apply_edits("clickhouse", "default", "events", &[]).await.unwrap_err();
|
||||
assert!(error.message.contains("table editing is unsupported"));
|
||||
assert!(!db.is_connected("clickhouse"));
|
||||
db.set_manual_commit("clickhouse", false).await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Sqlite => format!("EXPLAIN QUERY PLAN {s}"),
|
||||
DbDriver::Postgres => format!("EXPLAIN (FORMAT JSON) {s}"),
|
||||
DbDriver::Clickhouse => format!("EXPLAIN json = 1 {s}"),
|
||||
DbDriver::Mysql => format!("EXPLAIN {s}"),
|
||||
DbDriver::SqlServer => format!("SET SHOWPLAN_TEXT ON; {s}"),
|
||||
}
|
||||
@@ -44,7 +45,7 @@ pub fn explain_statement(driver: DbDriver, statement: &str) -> String {
|
||||
pub fn parse(driver: DbDriver, result: &QueryResult) -> IdeResult<Vec<PlanNode>> {
|
||||
match driver {
|
||||
DbDriver::Sqlite => Ok(sqlite(&result.rows)),
|
||||
DbDriver::Postgres => {
|
||||
DbDriver::Postgres | DbDriver::Clickhouse => {
|
||||
let text: String =
|
||||
result.rows.iter().filter_map(|r| r.first().cloned().flatten()).collect::<Vec<_>>().join("\n");
|
||||
let json: Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(format!("plan: {e}")))?;
|
||||
@@ -199,6 +200,7 @@ mod tests {
|
||||
assert_eq!(explain_statement(DbDriver::Sqlite, "select 1;"), "EXPLAIN QUERY PLAN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Postgres, " select 1 "), "EXPLAIN (FORMAT JSON) select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Mysql, "select 1"), "EXPLAIN select 1");
|
||||
assert_eq!(explain_statement(DbDriver::Clickhouse, " select 1;"), "EXPLAIN json = 1 select 1");
|
||||
assert_eq!(explain_statement(DbDriver::SqlServer, "select 1"), "SET SHOWPLAN_TEXT ON; select 1");
|
||||
}
|
||||
|
||||
@@ -254,6 +256,21 @@ mod tests {
|
||||
assert_eq!((plan[3].parent, plan[3].operation.as_str()), (Some(2), "Seq Scan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_clickhouse_json_plans_and_rejects_invalid_json() {
|
||||
let json = r#"[{"Plan": {"Node Type": "Expression", "Node Id": "Expression_5",
|
||||
"Description": "Project names", "Plans": [
|
||||
{"Node Type": "ReadFromMergeTree", "Node Id": "ReadFromMergeTree_0",
|
||||
"Description": "default.events"}
|
||||
]}}]"#;
|
||||
let plan = parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some(json)]])).unwrap();
|
||||
assert_eq!(plan.len(), 2);
|
||||
assert_eq!((plan[0].parent, plan[0].operation.as_str()), (None, "Expression"));
|
||||
assert_eq!((plan[1].parent, plan[1].operation.as_str()), (Some(0), "ReadFromMergeTree"));
|
||||
assert!(plan[1].properties.iter().any(|p| p.name == "Description" && p.value == "default.events"));
|
||||
assert!(parse(DbDriver::Clickhouse, &result(&["explain"], vec![vec![Some("invalid")]])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_mysql_rows() {
|
||||
let r = result(
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum DbDriver {
|
||||
Mysql,
|
||||
/// Microsoft SQL Server.
|
||||
SqlServer,
|
||||
Clickhouse,
|
||||
}
|
||||
|
||||
impl DbDriver {
|
||||
@@ -27,6 +28,7 @@ impl DbDriver {
|
||||
DbDriver::Postgres => Some(5432),
|
||||
DbDriver::Mysql => Some(3306),
|
||||
DbDriver::SqlServer => Some(1433),
|
||||
DbDriver::Clickhouse => Some(8123),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,6 +255,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
"mysql" | "mariadb" | "tidb" | "singlestore" | "memsql" | "oceanbase" => Some(DbDriver::Mysql),
|
||||
"sqlserver" | "mssql" | "mssqlserver" => Some(DbDriver::SqlServer),
|
||||
"sqlite" | "sqlite3" => Some(DbDriver::Sqlite),
|
||||
"clickhouse" | "http" | "https" => Some(DbDriver::Clickhouse),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -260,7 +263,7 @@ pub fn scheme_driver(scheme: &str) -> Option<DbDriver> {
|
||||
/// Parses `scheme://[user[:password]@]host[:port][/database][?k=v&…]`, `jdbc:` forms
|
||||
/// included, and `sqlite:path`.
|
||||
pub fn parse_url(raw: &str) -> IdeResult<ParsedUrl> {
|
||||
let bad = || IdeError::invalid(format!("not a database URL: {raw}"));
|
||||
let bad = || IdeError::invalid("invalid database URL");
|
||||
let url = raw.trim();
|
||||
let url = url.strip_prefix("jdbc:").or_else(|| url.strip_prefix("JDBC:")).unwrap_or(url);
|
||||
let (scheme, rest) = url.split_once(':').ok_or_else(bad)?;
|
||||
@@ -367,6 +370,15 @@ impl DataSource {
|
||||
}
|
||||
properties.append(&mut s.properties);
|
||||
s.properties = properties;
|
||||
if s.driver == DbDriver::Clickhouse {
|
||||
let raw_url = self.url.trim().to_ascii_lowercase();
|
||||
let url = raw_url.strip_prefix("jdbc:").unwrap_or(&raw_url);
|
||||
if url.starts_with("https://") {
|
||||
s.ssl = DbSslMode::VerifyFull;
|
||||
} else if url.starts_with("http://") {
|
||||
s.ssl = DbSslMode::Disable;
|
||||
}
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
@@ -519,4 +531,31 @@ mod tests {
|
||||
assert!(DataSource { url: "mysql://h/db".into(), ..s.clone() }.resolved().is_err());
|
||||
assert_eq!(DataSource { url_only: false, ..s }.resolved().unwrap().host, "localhost");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_urls_resolve_http_https_and_credentials() {
|
||||
assert_eq!(DbDriver::Clickhouse.default_port(), Some(8123));
|
||||
let source = DataSource {
|
||||
driver: DbDriver::Clickhouse,
|
||||
url_only: true,
|
||||
url: "https://reader:s%[email protected]:8443/analytics?max_execution_time=30".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let resolved = source.resolved().unwrap();
|
||||
assert_eq!(resolved.host, "db.example");
|
||||
assert_eq!(resolved.port, Some(8443));
|
||||
assert_eq!(resolved.database, "analytics");
|
||||
assert_eq!(resolved.user, "reader");
|
||||
assert_eq!(resolved.ssl, DbSslMode::VerifyFull);
|
||||
assert_eq!(source.url_password().as_deref(), Some("s@cret"));
|
||||
assert_eq!(resolved.properties[0].name, "max_execution_time");
|
||||
let http = DataSource { url: "http://localhost/default".into(), ..source.clone() }.resolved().unwrap();
|
||||
assert_eq!(http.ssl, DbSslMode::Disable);
|
||||
let jdbc = DataSource { url: "jdbc:clickhouse://localhost/analytics".into(), ..source.clone() };
|
||||
assert_eq!(jdbc.resolved().unwrap().database, "analytics");
|
||||
let secure = DataSource { url: "https://db.example/default?sslmode=disable".into(), ..source };
|
||||
assert_eq!(secure.resolved().unwrap().ssl, DbSslMode::VerifyFull);
|
||||
let error = parse_url("clickhouse://reader:[email protected]:invalid/default").unwrap_err();
|
||||
assert!(!error.message.contains("secret"));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ enum State {
|
||||
/// Every statement of `text` that contains more than whitespace and comments.
|
||||
pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
let mysql = driver == DbDriver::Mysql;
|
||||
let clickhouse = driver == DbDriver::Clickhouse;
|
||||
let chars: Vec<(usize, char)> = text.char_indices().collect();
|
||||
// UTF-16 offset of each char index (plus the end).
|
||||
let mut u16_at = Vec::with_capacity(chars.len() + 1);
|
||||
@@ -81,7 +82,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
state = State::Double;
|
||||
has_code = true;
|
||||
}
|
||||
'`' if mysql => {
|
||||
'`' if mysql || clickhouse => {
|
||||
state = State::Backtick;
|
||||
has_code = true;
|
||||
}
|
||||
@@ -117,7 +118,7 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
_ => {}
|
||||
},
|
||||
State::Single => {
|
||||
if mysql && c == '\\' {
|
||||
if (mysql || clickhouse) && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '\'' {
|
||||
if next == Some('\'') {
|
||||
@@ -128,7 +129,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Double => {
|
||||
if c == '"' {
|
||||
if clickhouse && c == '\\' {
|
||||
i += 1;
|
||||
} else if c == '"' {
|
||||
if next == Some('"') {
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -137,7 +140,9 @@ pub fn split(text: &str, driver: DbDriver) -> Vec<SqlStatement> {
|
||||
}
|
||||
}
|
||||
State::Backtick => {
|
||||
if c == '`' {
|
||||
if clickhouse && (c == '\\' || (c == '`' && next == Some('`'))) {
|
||||
i += 1;
|
||||
} else if c == '`' {
|
||||
state = State::Code;
|
||||
}
|
||||
}
|
||||
@@ -224,6 +229,12 @@ mod tests {
|
||||
assert_eq!(texts(sql, DbDriver::Mysql), vec!["select `a;b`, 'it\\'s;' from t # x;y", "select 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_backticks_and_escaped_quotes_keep_statements_intact() {
|
||||
let query = r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'; SELECT 2"#;
|
||||
assert_eq!(texts(query, DbDriver::Clickhouse), vec![r#"SELECT `a\`;b`, "c\";d", 'it\'s;safe'"#, "SELECT 2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn postgres_dollar_quotes() {
|
||||
let sql =
|
||||
|
||||
+106
-2
@@ -7,6 +7,7 @@ use specta::Type;
|
||||
|
||||
pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!("`{}`", name.replace('\\', "\\\\").replace('`', "\\`")),
|
||||
DbDriver::Mysql => format!("`{}`", name.replace('`', "``")),
|
||||
DbDriver::SqlServer => format!("[{}]", name.replace(']', "]]")),
|
||||
_ => format!("\"{}\"", name.replace('"', "\"\"")),
|
||||
@@ -15,7 +16,7 @@ pub fn quote_ident(driver: DbDriver, name: &str) -> String {
|
||||
|
||||
pub fn quote_literal(driver: DbDriver, value: &str) -> String {
|
||||
let mut s = value.replace('\'', "''");
|
||||
if driver == DbDriver::Mysql {
|
||||
if matches!(driver, DbDriver::Mysql | DbDriver::Clickhouse) {
|
||||
s = s.replace('\\', "\\\\");
|
||||
}
|
||||
format!("'{s}'")
|
||||
@@ -48,6 +49,10 @@ pub const INTERNAL_DATABASES: &[&str] = &["rdsadmin", "azure_maintenance", "azur
|
||||
pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Sqlite => "SELECT name FROM pragma_database_list WHERE name <> 'temp' ORDER BY seq",
|
||||
DbDriver::Clickhouse if system => "SELECT name FROM system.databases ORDER BY name",
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT name FROM system.databases WHERE lower(name) NOT IN ('system', 'information_schema') ORDER BY name"
|
||||
}
|
||||
DbDriver::Postgres if system => {
|
||||
"SELECT schema_name FROM information_schema.schemata \
|
||||
WHERE schema_name NOT LIKE 'pg\\_toast%' AND schema_name NOT LIKE 'pg\\_temp%' ORDER BY 1"
|
||||
@@ -73,6 +78,12 @@ pub fn schemas(driver: DbDriver, system: bool) -> &'static str {
|
||||
/// Rows: `name, kind` where kind is `table`, `view` or `materialized view`.
|
||||
pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, CASE engine WHEN 'View' THEN 'view' WHEN 'MaterializedView' THEN 'materialized view' \
|
||||
WHEN 'WindowView' THEN 'view' ELSE 'table' END FROM system.tables \
|
||||
WHERE database = {} AND is_temporary = 0 ORDER BY name",
|
||||
quote_literal(driver, schema)
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type FROM {}.sqlite_master WHERE type IN ('table', 'view') \
|
||||
AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' ORDER BY name",
|
||||
@@ -101,6 +112,12 @@ pub fn tables(driver: DbDriver, schema: &str) -> String {
|
||||
pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT name, type, CASE WHEN startsWith(type, 'Nullable(') \
|
||||
OR startsWith(type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, \
|
||||
if(default_kind = '', NULL, default_expression), is_in_primary_key \
|
||||
FROM system.columns WHERE database = {s} AND table = {t} ORDER BY position"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, type, CASE WHEN \"notnull\" = 0 THEN 'YES' ELSE 'NO' END, dflt_value, \
|
||||
CASE WHEN pk > 0 THEN 1 ELSE 0 END FROM pragma_table_info({t}, {s}) ORDER BY cid"
|
||||
@@ -138,6 +155,12 @@ pub fn columns(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => format!(
|
||||
"SELECT * FROM (SELECT 'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression \
|
||||
FROM system.tables WHERE database = {s} AND name = {t} AND primary_key != '' \
|
||||
UNION ALL SELECT name, 0, expr FROM system.data_skipping_indices \
|
||||
WHERE database = {s} AND table = {t}) ORDER BY index_name"
|
||||
),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT il.name, il.\"unique\", ii.name FROM pragma_index_list({t}, {s}) il \
|
||||
JOIN pragma_index_info(il.name, {s}) ii ORDER BY il.name, ii.seqno"
|
||||
@@ -169,6 +192,15 @@ pub fn indexes(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
/// for SQL completion, grouped by table in column order.
|
||||
pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => {
|
||||
"SELECT c.database, c.table, CASE t.engine WHEN 'View' THEN 'view' \
|
||||
WHEN 'MaterializedView' THEN 'materialized view' WHEN 'WindowView' THEN 'view' ELSE 'table' END, \
|
||||
c.name, c.type, CASE WHEN startsWith(c.type, 'Nullable(') \
|
||||
OR startsWith(c.type, 'LowCardinality(Nullable(') THEN 'YES' ELSE 'NO' END, c.is_in_primary_key, 1 \
|
||||
FROM system.columns c JOIN system.tables t ON t.database = c.database AND t.name = c.table \
|
||||
WHERE lower(c.database) NOT IN ('system', 'information_schema') AND t.is_temporary = 0 \
|
||||
ORDER BY c.database, c.table, c.position LIMIT 50000"
|
||||
}
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, m.type, p.name, p.type, CASE WHEN p.\"notnull\" = 0 THEN 'YES' ELSE 'NO' END, \
|
||||
CASE WHEN p.pk > 0 THEN 1 ELSE 0 END, \
|
||||
@@ -221,6 +253,7 @@ pub fn completion_columns(driver: DbDriver) -> &'static str {
|
||||
/// when the key references the primary key implicitly.
|
||||
pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '', '', '', '', '' WHERE 0",
|
||||
DbDriver::Sqlite => {
|
||||
"SELECT 'main', m.name, f.id, f.\"from\", 'main', f.\"table\", f.\"to\" \
|
||||
FROM sqlite_master m JOIN pragma_foreign_key_list(m.name) f \
|
||||
@@ -258,6 +291,7 @@ pub fn foreign_keys(driver: DbDriver) -> &'static str {
|
||||
pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => foreign_keys(driver).to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT {s}, {t}, f.id, f.\"from\", {s}, f.\"table\", f.\"to\" FROM pragma_foreign_key_list({t}, {s}) f ORDER BY f.id, f.seq"
|
||||
),
|
||||
@@ -276,6 +310,7 @@ pub fn table_foreign_keys(driver: DbDriver, schema: &str, table: &str) -> String
|
||||
/// The schema unqualified names resolve to (`None`: always `main`).
|
||||
pub fn current_schema(driver: DbDriver) -> Option<&'static str> {
|
||||
match driver {
|
||||
DbDriver::Clickhouse => Some("SELECT currentDatabase()"),
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Postgres => Some("SELECT current_schema()"),
|
||||
DbDriver::Mysql => Some("SELECT DATABASE()"),
|
||||
@@ -362,6 +397,9 @@ fn where_key(driver: DbDriver, key: &[CellValue]) -> String {
|
||||
/// The DML statement for `edit`. Values are written as quoted literals, which
|
||||
/// every supported database coerces to the column type.
|
||||
pub fn edit_statement(driver: DbDriver, schema: &str, table: &str, edit: &DbRowEdit) -> Result<String, String> {
|
||||
if driver == DbDriver::Clickhouse {
|
||||
return Err("ClickHouse table editing is unsupported: primary keys do not uniquely identify rows and edits require transactions".into());
|
||||
}
|
||||
let target = qualified(driver, schema, table);
|
||||
match edit {
|
||||
DbRowEdit::Update { key, values } => {
|
||||
@@ -450,6 +488,7 @@ pub fn postgres_ddl_indexes(oid: u32) -> String {
|
||||
pub fn triggers(driver: DbDriver, schema: &str, table: &str) -> String {
|
||||
let (s, t) = (quote_literal(driver, schema), quote_literal(driver, table));
|
||||
match driver {
|
||||
DbDriver::Clickhouse => "SELECT '', '', '' WHERE 0".to_owned(),
|
||||
DbDriver::Sqlite => format!(
|
||||
"SELECT name, sql FROM {}.sqlite_master WHERE type = 'trigger' AND tbl_name = {t} ORDER BY name",
|
||||
quote_ident(driver, schema)
|
||||
@@ -492,7 +531,7 @@ pub fn sqlite_trigger_event(sql: &str) -> (String, String) {
|
||||
pub fn routines(driver: DbDriver, schema: &str) -> Option<String> {
|
||||
let s = quote_literal(driver, schema);
|
||||
match driver {
|
||||
DbDriver::Sqlite => None,
|
||||
DbDriver::Sqlite | DbDriver::Clickhouse => None,
|
||||
// Functions of extensions are left out, as in IDEA.
|
||||
DbDriver::Postgres => Some(format!(
|
||||
"SELECT p.proname, CASE WHEN p.prokind = 'p' THEN 'procedure' ELSE 'function' END, \
|
||||
@@ -625,6 +664,71 @@ mod tests {
|
||||
assert_eq!(quote_literal(DbDriver::Mysql, "it's \\"), "'it''s \\\\'");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_escapes_backslashes_and_delimiters() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(quote_literal(driver, r"path\O'Brien"), r"'path\\O''Brien'");
|
||||
assert_eq!(quote_literal(driver, r"\' OR 1 = 1 --"), r"'\\'' OR 1 = 1 --'");
|
||||
assert_eq!(quote_ident(driver, r"a\`b"), r"`a\\\`b`");
|
||||
assert_eq!(qualified(driver, "data base", "event`log"), r"`data base`.`event\`log`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_metadata_preserves_types_defaults_and_nonunique_keys() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(schemas(driver, true), "SELECT name FROM system.databases ORDER BY name");
|
||||
assert!(schemas(driver, false).contains("lower(name) NOT IN ('system', 'information_schema')"));
|
||||
assert_eq!(current_schema(driver), Some("SELECT currentDatabase()"));
|
||||
let relations = tables(driver, "event's\\db");
|
||||
assert!(relations.contains("WHEN 'MaterializedView' THEN 'materialized view'"));
|
||||
assert!(relations.contains(r"WHERE database = 'event''s\\db' AND is_temporary = 0"));
|
||||
let fields = columns(driver, "default", "events");
|
||||
assert!(fields.starts_with("SELECT name, type, CASE WHEN startsWith(type, 'Nullable(')"));
|
||||
assert!(fields.contains("startsWith(type, 'LowCardinality(Nullable(')"));
|
||||
assert!(fields.contains("if(default_kind = '', NULL, default_expression), is_in_primary_key"));
|
||||
assert!(fields.ends_with("WHERE database = 'default' AND table = 'events' ORDER BY position"));
|
||||
let keys = indexes(driver, "default", "events");
|
||||
assert!(keys.contains("'PRIMARY KEY' AS index_name, 0 AS is_unique, primary_key AS expression"));
|
||||
assert!(keys.contains("SELECT name, 0, expr FROM system.data_skipping_indices"));
|
||||
let completion = completion_columns(driver);
|
||||
assert!(completion.contains("c.is_in_primary_key, 1"));
|
||||
assert!(completion.contains("ORDER BY c.database, c.table, c.position LIMIT 50000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_has_no_foreign_key_trigger_or_schema_routine_catalogs() {
|
||||
let driver = DbDriver::Clickhouse;
|
||||
assert_eq!(foreign_keys(driver), "SELECT '', '', '', '', '', '', '' WHERE 0");
|
||||
assert_eq!(table_foreign_keys(driver, "default", "events"), foreign_keys(driver));
|
||||
assert_eq!(triggers(driver, "default", "events"), "SELECT '', '', '' WHERE 0");
|
||||
assert!(routines(driver, "default").is_none());
|
||||
assert!(sequences(driver, "default").is_none());
|
||||
assert!(object_ddl(driver, "default", crate::DbObjectKind::Trigger, "t").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_pages_quote_keys_and_fetch_one_extra_row() {
|
||||
let sort = [SortKey { column: "event`id".into(), descending: true }];
|
||||
assert_eq!(
|
||||
table_page(DbDriver::Clickhouse, "default", "events", Some("WHERE id > 3"), &sort, 50, 25),
|
||||
r"SELECT * FROM `default`.`events` WHERE id > 3 ORDER BY `event\`id` DESC LIMIT 26 OFFSET 50"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clickhouse_keys_never_enable_transactional_table_edits() {
|
||||
let key = vec![cv("id", Some("1"))];
|
||||
for edit in [
|
||||
DbRowEdit::Update { key: key.clone(), values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Delete { key },
|
||||
DbRowEdit::Insert { values: vec![cv("name", Some("new"))] },
|
||||
DbRowEdit::Insert { values: vec![] },
|
||||
] {
|
||||
let error = edit_statement(DbDriver::Clickhouse, "default", "events", &edit).unwrap_err();
|
||||
assert!(error.contains("primary keys do not uniquely identify rows"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pages() {
|
||||
let sort = [SortKey { column: "id".into(), descending: true }];
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_db::{CellValue, DataSource, DbDriver, DbManager, DbRowEdit, DbSslMode, SortKey, sql};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn check(condition: bool, message: &str) -> IdeResult<()> {
|
||||
if condition { Ok(()) } else { Err(IdeError::invalid(message)) }
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires CLICKHOUSE_TEST_HOST, CLICKHOUSE_TEST_USER, and CLICKHOUSE_TEST_PASSWORD"]
|
||||
async fn clickhouse_cloud_manager_catalogs_and_session_table() -> IdeResult<()> {
|
||||
let tmp = tempfile::tempdir()?;
|
||||
let root = tmp.path().to_path_buf();
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
let mut source = DataSource {
|
||||
id: "clickhouse".into(),
|
||||
name: "clickhouse".into(),
|
||||
driver: DbDriver::Clickhouse,
|
||||
host: std::env::var("CLICKHOUSE_TEST_HOST").expect("set CLICKHOUSE_TEST_HOST"),
|
||||
port: Some(
|
||||
std::env::var("CLICKHOUSE_TEST_PORT")
|
||||
.map(|value| value.parse().expect("numeric CLICKHOUSE_TEST_PORT"))
|
||||
.unwrap_or(8443),
|
||||
),
|
||||
user: std::env::var("CLICKHOUSE_TEST_USER").expect("set CLICKHOUSE_TEST_USER"),
|
||||
database: "default".into(),
|
||||
ssl: DbSslMode::VerifyFull,
|
||||
..Default::default()
|
||||
};
|
||||
source.introspection.show_system = true;
|
||||
let password = std::env::var("CLICKHOUSE_TEST_PASSWORD").expect("set CLICKHOUSE_TEST_PASSWORD");
|
||||
check(!db.test(&source, Some(password.clone()), None).await?.is_empty(), "missing server version")?;
|
||||
db.save_sources(vec![source]).await?;
|
||||
db.set_password("clickhouse", Some(password), false).await?;
|
||||
let cancel = CancellationToken::new();
|
||||
let suffix = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos();
|
||||
let name = format!("manager_test_{}_{suffix}", std::process::id());
|
||||
let table = sql::quote_ident(DbDriver::Clickhouse, &name);
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!(
|
||||
"CREATE TEMPORARY TABLE {table} (id UInt64, label Nullable(String) DEFAULT NULL, \
|
||||
category LowCardinality(Nullable(String)) DEFAULT NULL, items Array(Nullable(UInt8)), \
|
||||
label_size UInt64 ALIAS length(ifNull(label, ''))) ENGINE = Memory"
|
||||
),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let checked: IdeResult<()> = async {
|
||||
db.execute(
|
||||
"clickhouse",
|
||||
&format!("INSERT INTO {table} (id, label, items) VALUES (1, 'first', [1, NULL]), (1, NULL, [])"),
|
||||
0,
|
||||
&cancel,
|
||||
)
|
||||
.await?;
|
||||
let schemas = db.schemas("clickhouse").await?;
|
||||
check(schemas.iter().any(|schema| schema == "default"), "default database missing")?;
|
||||
check(schemas.iter().any(|schema| schema == "system"), "system database missing")?;
|
||||
let tables = db.tables("clickhouse", "system").await?;
|
||||
check(tables.iter().any(|table| table.name == "one"), "system table listing missing one")?;
|
||||
let details = db.table_details("clickhouse", "", &name).await?;
|
||||
check(details.columns.len() == 5, "temporary table columns missing")?;
|
||||
for (name, data_type, nullable) in [
|
||||
("id", "UInt64", false),
|
||||
("label", "Nullable(String)", true),
|
||||
("category", "LowCardinality(Nullable(String))", true),
|
||||
("items", "Array(Nullable(UInt8))", false),
|
||||
] {
|
||||
check(
|
||||
details
|
||||
.columns
|
||||
.iter()
|
||||
.any(|column| column.name == name && column.data_type == data_type && column.nullable == nullable),
|
||||
"column type or nullability mismatch",
|
||||
)?;
|
||||
}
|
||||
check(
|
||||
details.columns.iter().any(|column| column.name == "label" && column.default.as_deref() == Some("NULL")),
|
||||
"explicit default expression missing",
|
||||
)?;
|
||||
check(details.indexes.iter().all(|index| !index.unique), "ClickHouse index reported unique")?;
|
||||
check(details.foreign_keys.is_empty() && details.triggers.is_empty(), "unsupported table objects returned")?;
|
||||
let sort = [SortKey { column: "id".into(), descending: false }];
|
||||
let first = db.table_page("clickhouse", "", &name, None, &sort, 0, 1, &cancel).await?;
|
||||
check(first.result.rows.len() == 1 && first.result.has_more, "first page limit failed")?;
|
||||
let second = db.table_page("clickhouse", "", &name, None, &sort, 1, 1, &cancel).await?;
|
||||
check(second.result.rows.len() == 1 && !second.result.has_more, "page offset failed")?;
|
||||
let ddl = db.ddl("clickhouse", "", &name).await?;
|
||||
check(ddl.contains(&name) && ddl.contains("Memory"), "temporary table DDL missing")?;
|
||||
let completion = db.completion("clickhouse").await?;
|
||||
check(completion.default_schema == "default", "completion default database mismatch")?;
|
||||
check(completion.foreign_keys.is_empty(), "completion returned unsupported foreign keys")?;
|
||||
if let Some(table) = completion.tables.first() {
|
||||
let details = db.table_details("clickhouse", &table.schema, &table.name).await?;
|
||||
check(
|
||||
table.columns.iter().all(|column| {
|
||||
details.columns.iter().any(|field| field.name == column.name && field.data_type == column.data_type)
|
||||
}),
|
||||
"completion columns differ from catalog",
|
||||
)?;
|
||||
}
|
||||
let plan = db.explain("clickhouse", &format!("SELECT id FROM {table}"), &cancel).await?;
|
||||
check(!plan.is_empty(), "query plan missing")?;
|
||||
let value = r"path\O'Brien";
|
||||
let result = db
|
||||
.execute("clickhouse", &format!("SELECT {}", sql::quote_literal(DbDriver::Clickhouse, value)), 1, &cancel)
|
||||
.await?;
|
||||
check(result.rows == vec![vec![Some(value.to_owned())]], "quoted literal changed value")?;
|
||||
let edits = [DbRowEdit::Update {
|
||||
key: vec![CellValue { column: "id".into(), value: Some("1".into()) }],
|
||||
values: vec![CellValue { column: "label".into(), value: Some("changed".into()) }],
|
||||
}];
|
||||
check(
|
||||
db.apply_edits("clickhouse", "", &name, &edits)
|
||||
.await
|
||||
.err()
|
||||
.is_some_and(|error| error.message.contains("table editing is unsupported")),
|
||||
"supplied row key authorized table editing",
|
||||
)?;
|
||||
check(db.set_manual_commit("clickhouse", true).await.is_err(), "manual transactions were accepted")?;
|
||||
for commit in [false, true] {
|
||||
check(db.finish_transaction("clickhouse", commit).await.is_err(), "transaction control was accepted")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
let cleanup = db.execute("clickhouse", &format!("DROP TEMPORARY TABLE {table}"), 0, &cancel).await;
|
||||
db.disconnect("clickhouse").await;
|
||||
checked?;
|
||||
cleanup?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -197,7 +197,7 @@ async fn count(db: &DbManager) -> String {
|
||||
async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResult<()> {
|
||||
let (_tmp, db) = setup().await;
|
||||
let c = CancellationToken::new();
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "INSERT INTO users (name) VALUES ('dee')", 1, &c).await?;
|
||||
// The session sees its own change.
|
||||
assert_eq!(count(&db).await, "4");
|
||||
@@ -208,10 +208,10 @@ async fn manual_commit_keeps_a_transaction_until_commit_or_rollback() -> IdeResu
|
||||
db.finish_transaction("s1", true).await?;
|
||||
// Leaving manual mode rolls back what is still open.
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.set_manual_commit("s1", false).await;
|
||||
db.set_manual_commit("s1", false).await.unwrap();
|
||||
assert_eq!(count(&db).await, "4");
|
||||
// Disconnecting with an open transaction does not hang.
|
||||
db.set_manual_commit("s1", true).await;
|
||||
db.set_manual_commit("s1", true).await.unwrap();
|
||||
db.execute("s1", "DELETE FROM users", 1, &c).await?;
|
||||
db.disconnect("s1").await;
|
||||
assert_eq!(count(&db).await, "4");
|
||||
|
||||
@@ -27,8 +27,8 @@ mod when;
|
||||
pub use capability::{Capability, CapabilityInfo, Risk, host_matches};
|
||||
pub use index::ContributionIndex;
|
||||
pub use manifest::{
|
||||
ActivationEvent, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, Manifest, check_relative,
|
||||
engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
ActivationEvent, CATEGORIES, Capabilities, ENGINE_VERSION, EVENT_KINDS, LEGACY_API, MANIFEST_FILE, MAX_CATEGORIES,
|
||||
Manifest, check_relative, engine_compatible, validate_item_id, validate_plugin_id, validate_point_id,
|
||||
};
|
||||
pub use point::{Commit, Disposable, ExtensionPoint, MAX_PACKAGE_FILE, Package, Placement, Prepared, decode};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, PreparedSet};
|
||||
|
||||
@@ -35,6 +35,11 @@ pub const MANIFEST_FILE: &str = "plugin.toml";
|
||||
pub const ENGINE_VERSION: &str = "1.0.0";
|
||||
/// The 0.1 API, still accepted for contribution-only packages and 0.1 components.
|
||||
pub const LEGACY_API: &str = "0.1";
|
||||
/// Marketplace categories, in the order they are shown.
|
||||
pub const CATEGORIES: &[&str] =
|
||||
&["languages", "themes", "icon-themes", "linters", "vcs", "ai", "frameworks", "tools", "other"];
|
||||
/// Categories a plugin is listed under; later ones are ignored.
|
||||
pub const MAX_CATEGORIES: usize = 3;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Manifest {
|
||||
@@ -47,6 +52,8 @@ pub struct Manifest {
|
||||
pub license: Option<String>,
|
||||
/// Release notes of this version (Settings › Plugins › What's New).
|
||||
pub change_notes: Option<String>,
|
||||
/// Marketplace categories as declared (see [`Manifest::categories`]).
|
||||
pub declared_categories: Vec<String>,
|
||||
/// `engines.openidea` (a semver requirement), or the legacy `openidea-api`.
|
||||
pub engine: String,
|
||||
/// Written in the 0.1 format.
|
||||
@@ -166,6 +173,8 @@ struct Raw {
|
||||
#[serde(default)]
|
||||
change_notes: Option<String>,
|
||||
#[serde(default)]
|
||||
categories: Vec<String>,
|
||||
#[serde(default)]
|
||||
engines: Option<Engines>,
|
||||
/// 0.1 format.
|
||||
#[serde(default)]
|
||||
@@ -243,6 +252,7 @@ impl Manifest {
|
||||
homepage: raw.homepage,
|
||||
license: raw.license,
|
||||
change_notes: raw.change_notes,
|
||||
declared_categories: raw.categories,
|
||||
engine,
|
||||
legacy,
|
||||
wasm: raw.wasm,
|
||||
@@ -290,6 +300,9 @@ impl Manifest {
|
||||
return Err(format!("duplicate activation event {event}"));
|
||||
}
|
||||
}
|
||||
for c in &self.declared_categories {
|
||||
validate_item_id(c, "category")?;
|
||||
}
|
||||
for (point, decls) in &self.contributes {
|
||||
validate_point_id(point)?;
|
||||
if !matches!(decls, toml::Value::Array(_) | toml::Value::Table(_)) {
|
||||
@@ -325,6 +338,39 @@ impl Manifest {
|
||||
}
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
/// Its marketplace categories, in [`CATEGORIES`] order: the first
|
||||
/// [`MAX_CATEGORIES`] declared that this IDE knows; without any, those its
|
||||
/// contributions imply (themes, icon themes, languages), else `other`.
|
||||
pub fn categories(&self) -> Vec<String> {
|
||||
let pick = |f: &dyn Fn(&str) -> bool| CATEGORIES.iter().filter(|c| f(c)).map(|c| c.to_string()).collect();
|
||||
let mut known: Vec<&str> = Vec::new();
|
||||
for d in &self.declared_categories {
|
||||
if CATEGORIES.contains(&d.as_str()) && !known.contains(&d.as_str()) && known.len() < MAX_CATEGORIES {
|
||||
known.push(d);
|
||||
}
|
||||
}
|
||||
let mut out: Vec<String> = pick(&|c| known.contains(&c));
|
||||
if out.is_empty() {
|
||||
out = pick(&|c| match c {
|
||||
"themes" => self.contributes.contains_key("themes"),
|
||||
"icon-themes" => self.contributes.contains_key("iconThemes"),
|
||||
"languages" => self.contributes.contains_key("languages"),
|
||||
_ => false,
|
||||
});
|
||||
}
|
||||
if out.is_empty() {
|
||||
out.push("other".into());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Declared categories this IDE doesn't know (ignored).
|
||||
pub fn unknown_categories(&self) -> Vec<&str> {
|
||||
self.declared_categories.iter().map(String::as_str).filter(|c| !CATEGORIES.contains(c)).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this host can load a plugin written for `engine`.
|
||||
pub fn engine_compatible(engine: &str, legacy: bool) -> bool {
|
||||
if legacy {
|
||||
@@ -417,6 +463,26 @@ mod tests {
|
||||
assert!(ActivationEvent::parse("on-event:scheduled").is_err(), "declared with on-schedule");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn categories_are_declared_or_implied() {
|
||||
let head = "id = \"acme.x\"\nname = \"X\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n";
|
||||
let m = |rest: &str| Manifest::parse(&format!("{head}{rest}"));
|
||||
// Declared, in the catalogue's order; unknown ones (a newer IDE's) are ignored.
|
||||
let declared = m("categories = [\"tools\", \"vcs\", \"robots\"]\n").unwrap();
|
||||
assert_eq!(declared.categories(), ["vcs", "tools"]);
|
||||
assert_eq!(declared.unknown_categories(), ["robots"]);
|
||||
// None declared: implied by the contributions, else `other`.
|
||||
let theme = m("[[contributes.themes]]\nid = \"t\"\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
|
||||
assert_eq!(theme.categories(), ["languages", "themes"]);
|
||||
let declared_wins = m("categories = [\"tools\"]\n[[contributes.languages]]\nid = \"l\"\n").unwrap();
|
||||
assert_eq!(declared_wins.categories(), ["tools"]);
|
||||
assert_eq!(m("").unwrap().categories(), ["other"]);
|
||||
// At most three: the first ones declared.
|
||||
let many = m("categories = [\"vcs\", \"ai\", \"vcs\", \"tools\", \"themes\"]\n").unwrap();
|
||||
assert_eq!(many.categories(), ["vcs", "ai", "tools"]);
|
||||
assert!(m("categories = [\"Bad Name\"]\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_02_format() {
|
||||
let m = Manifest::parse(FULL).unwrap();
|
||||
|
||||
@@ -9,9 +9,19 @@
|
||||
//! proves who published the bytes; whether to trust that publisher is the
|
||||
//! index's call.
|
||||
//! - **Index** (`index.json`): the publishers and their keys (the trust root),
|
||||
//! the published plugins, and revocations. A publisher owns the id prefix
|
||||
//! `<publisher>.`; a revoked key invalidates everything it signed, a
|
||||
//! revoked plugin version is disabled where it is installed.
|
||||
//! the published plugin versions, and revocations. A publisher owns the id
|
||||
//! prefix `<publisher>.`; a revoked key invalidates everything it signed, a
|
||||
//! revoked plugin version is disabled where it is installed. A plugin may be
|
||||
//! listed in several versions, each with the plugin API it needs: the IDE
|
||||
//! offers the newest one it can load.
|
||||
//! - **Key continuity**: an installed plugin updates silently only to a
|
||||
//! package signed with the same key, or with a key the installed one
|
||||
//! endorsed ([`Endorsement`], a publisher rotating keys); any other signer
|
||||
//! is the user's call. An IDE can pin a publisher to keys it was built with
|
||||
//! ([`Index::pin_publisher`]): the index then can't replace them.
|
||||
//! - **Format**: fields are only ever added (IDEs ignore those they don't
|
||||
//! know); `format` grows only for changes older IDEs can't read, and they
|
||||
//! still apply such an index's revocations ([`Index::parse`]).
|
||||
|
||||
use crate::MANIFEST_FILE;
|
||||
use base64::Engine as _;
|
||||
@@ -40,6 +50,24 @@ fn signed_message(id: &str, version: &str, digest: &str) -> Vec<u8> {
|
||||
format!("openidea-plugin/1\n{id}\n{version}\n{digest}").into_bytes()
|
||||
}
|
||||
|
||||
fn endorsement_message(publisher: &str, key: &str) -> Vec<u8> {
|
||||
format!("openidea-key/1\n{publisher}\n{key}").into_bytes()
|
||||
}
|
||||
|
||||
/// Whether `signature` (base64) is `key`'s (base64) Ed25519 signature of
|
||||
/// `message`; malformed keys and signatures are errors.
|
||||
fn verify_ed25519(key: &str, message: &[u8], signature: &str) -> Result<bool, String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] =
|
||||
B64.decode(signature).ok().and_then(|b| b.try_into().ok()).ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
Ok(key.verify_strict(message, &Signature::from_bytes(&sig)).is_ok())
|
||||
}
|
||||
|
||||
/// `SIGNATURE.json` of a signed package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -74,6 +102,12 @@ impl PublisherKey {
|
||||
B64.encode(self.0.verifying_key().to_bytes())
|
||||
}
|
||||
|
||||
/// Vouches for `publisher`'s new key `key` (base64), the one that follows this one.
|
||||
pub fn endorse(&self, publisher: &str, key: &str) -> Endorsement {
|
||||
let sig = self.0.sign(&endorsement_message(publisher, key));
|
||||
Endorsement { key: self.public_base64(), signature: B64.encode(sig.to_bytes()) }
|
||||
}
|
||||
|
||||
pub fn sign(&self, publisher: &str, id: &str, version: &str, digest: &str) -> PackageSignature {
|
||||
let sig = self.0.sign(&signed_message(id, version, digest));
|
||||
PackageSignature {
|
||||
@@ -88,19 +122,11 @@ impl PackageSignature {
|
||||
/// Whether the signature is valid for `id`, `version` and `digest` (the
|
||||
/// key's validity is checked against an [`Index`]).
|
||||
pub fn check(&self, id: &str, version: &str, digest: &str) -> Result<(), String> {
|
||||
let key: [u8; 32] = B64
|
||||
.decode(&self.key)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed public key".to_owned())?;
|
||||
let key = VerifyingKey::from_bytes(&key).map_err(|_| "signature: invalid public key".to_owned())?;
|
||||
let sig: [u8; 64] = B64
|
||||
.decode(&self.signature)
|
||||
.ok()
|
||||
.and_then(|b| b.try_into().ok())
|
||||
.ok_or_else(|| "signature: malformed".to_owned())?;
|
||||
key.verify_strict(&signed_message(id, version, digest), &Signature::from_bytes(&sig))
|
||||
.map_err(|_| "the signature does not match the package (it was changed after signing)".to_owned())
|
||||
if verify_ed25519(&self.key, &signed_message(id, version, digest), &self.signature)? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("the signature does not match the package (it was changed after signing)".to_owned())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +268,10 @@ pub struct Index {
|
||||
pub plugins: Vec<IndexEntry>,
|
||||
#[serde(default)]
|
||||
pub revoked: Vec<Revocation>,
|
||||
/// The marketplace moved: its index is at this URL (relative to this
|
||||
/// one, or absolute), and the IDE reads that one instead.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub moved: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
@@ -253,7 +283,7 @@ pub struct Publisher {
|
||||
pub keys: Vec<KeyEntry>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct KeyEntry {
|
||||
/// Ed25519 public key, base64.
|
||||
@@ -261,6 +291,27 @@ pub struct KeyEntry {
|
||||
/// Packages signed with it are no longer trusted.
|
||||
#[serde(default)]
|
||||
pub revoked: bool,
|
||||
/// The publisher's earlier key vouching for this one.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endorsement: Option<Endorsement>,
|
||||
}
|
||||
|
||||
impl KeyEntry {
|
||||
pub fn new(key: String) -> Self {
|
||||
Self { key, ..Self::default() }
|
||||
}
|
||||
}
|
||||
|
||||
/// A publisher's earlier key vouching for a newer one: plugins installed from
|
||||
/// packages signed with the earlier key update to ones signed with the newer
|
||||
/// key without asking the user.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Endorsement {
|
||||
/// The endorsing key, base64.
|
||||
pub key: String,
|
||||
/// Its Ed25519 signature of `openidea-key/1\n<publisher>\n<endorsed key>`, base64.
|
||||
pub signature: String,
|
||||
}
|
||||
|
||||
/// A published plugin version.
|
||||
@@ -281,6 +332,34 @@ pub struct IndexEntry {
|
||||
pub digest: String,
|
||||
#[serde(default)]
|
||||
pub capabilities: Vec<String>,
|
||||
/// The plugin API versions it loads in (a semver requirement): its
|
||||
/// manifest's `engines.openidea` and the API its component was built for.
|
||||
/// Required: an entry without one is offered to no IDE.
|
||||
#[serde(default)]
|
||||
pub engine: String,
|
||||
/// Marketplace categories (`CATEGORIES`); an IDE ignores those it doesn't know.
|
||||
#[serde(default)]
|
||||
pub categories: Vec<String>,
|
||||
}
|
||||
|
||||
impl IndexEntry {
|
||||
/// Whether this IDE can load the version.
|
||||
pub fn compatible(&self) -> bool {
|
||||
crate::engine_compatible(&self.engine, false)
|
||||
}
|
||||
|
||||
fn semver(&self) -> Option<semver::Version> {
|
||||
semver::Version::parse(&self.version).ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// `engines.openidea` of a manifest narrowed to the API its component was built
|
||||
/// for (`major.minor`, if it has code): the requirement an index lists.
|
||||
pub fn engine_requirement(manifest_engine: &str, built_for: Option<&semver::Version>) -> String {
|
||||
match built_for {
|
||||
Some(v) => format!("{manifest_engine}, ^{}.{}", v.major, v.minor),
|
||||
None => manifest_engine.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A plugin (all versions when `versions` is empty) that must not run.
|
||||
@@ -294,12 +373,71 @@ pub struct Revocation {
|
||||
}
|
||||
|
||||
impl Index {
|
||||
/// Parses an index. One in a newer format keeps what must reach every IDE —
|
||||
/// revocations, publisher keys (revoked ones) and `moved` — and lists no
|
||||
/// plugins ([`Index::is_newer`]): the IDE needs updating to install from it.
|
||||
pub fn parse(text: &str) -> Result<Self, String> {
|
||||
let index: Index = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
if index.format != INDEX_FORMAT {
|
||||
return Err(format!("index format {} is not supported (this IDE reads {INDEX_FORMAT})", index.format));
|
||||
let value: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("index: {e}"))?;
|
||||
let format = value.get("format").and_then(serde_json::Value::as_u64).ok_or("index: `format` is missing")?;
|
||||
if format == u64::from(INDEX_FORMAT) {
|
||||
return serde_json::from_value(value).map_err(|e| format!("index: {e}"));
|
||||
}
|
||||
Ok(index)
|
||||
if format < u64::from(INDEX_FORMAT) {
|
||||
return Err(format!("index format {format} is not supported (this IDE reads {INDEX_FORMAT})"));
|
||||
}
|
||||
fn each<T: serde::de::DeserializeOwned>(value: &serde_json::Value, field: &str) -> Vec<T> {
|
||||
let items = value.get(field).and_then(serde_json::Value::as_array);
|
||||
items.into_iter().flatten().filter_map(|v| serde_json::from_value(v.clone()).ok()).collect()
|
||||
}
|
||||
Ok(Index {
|
||||
format: u32::try_from(format).unwrap_or(u32::MAX),
|
||||
publishers: each(&value, "publishers"),
|
||||
plugins: Vec::new(),
|
||||
revoked: each(&value, "revoked"),
|
||||
moved: value.get("moved").and_then(serde_json::Value::as_str).map(str::to_owned),
|
||||
})
|
||||
}
|
||||
|
||||
/// Written in a format newer than this IDE reads: it lists no plugins.
|
||||
pub fn is_newer(&self) -> bool {
|
||||
self.format > INDEX_FORMAT
|
||||
}
|
||||
|
||||
/// The version of each plugin to offer, and whether this IDE can load it:
|
||||
/// the newest it can load, else the newest. Revoked versions are skipped;
|
||||
/// plugins come in the order they are first listed.
|
||||
pub fn offered(&self) -> Vec<(&IndexEntry, bool)> {
|
||||
let mut ids: Vec<&str> = Vec::new();
|
||||
for e in &self.plugins {
|
||||
if !ids.contains(&e.id.as_str()) {
|
||||
ids.push(&e.id);
|
||||
}
|
||||
}
|
||||
ids.into_iter()
|
||||
.filter_map(|id| {
|
||||
let versions: Vec<&IndexEntry> =
|
||||
self.plugins.iter().filter(|e| e.id == id && self.revocation(id, &e.version).is_none()).collect();
|
||||
let newest = |loadable: bool| {
|
||||
versions.iter().copied().filter(|e| !loadable || e.compatible()).max_by_key(|e| e.semver())
|
||||
};
|
||||
newest(true).map(|e| (e, true)).or_else(|| newest(false).map(|e| (e, false)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drops the versions no IDE would pick: revoked ones, and of each plugin
|
||||
/// all but the newest version per API requirement.
|
||||
pub fn prune_versions(&mut self) {
|
||||
let plugins = std::mem::take(&mut self.plugins);
|
||||
let mut kept: Vec<IndexEntry> = Vec::new();
|
||||
for e in plugins.into_iter().filter(|e| self.revocation(&e.id, &e.version).is_none()) {
|
||||
match kept.iter_mut().find(|k| k.id == e.id && k.engine == e.engine) {
|
||||
Some(k) if e.semver() > k.semver() => *k = e,
|
||||
Some(_) => {}
|
||||
None => kept.push(e),
|
||||
}
|
||||
}
|
||||
self.plugins = kept;
|
||||
}
|
||||
|
||||
/// The revocation that applies to `id` at `version`.
|
||||
@@ -335,6 +473,127 @@ impl Index {
|
||||
sig.check(id, version, digest)?;
|
||||
Ok(publisher.id.clone())
|
||||
}
|
||||
|
||||
/// Whether `to` is `from`, or a key of `publisher` that `from` endorsed,
|
||||
/// directly or through keys it endorsed. Endorsements by revoked keys don't
|
||||
/// count: whoever stole a key could vouch for their own.
|
||||
pub fn succeeds(&self, publisher: &str, from: &str, to: &str) -> bool {
|
||||
let keys = self.publishers.iter().find(|p| p.id == publisher).map_or(&[][..], |p| &p.keys[..]);
|
||||
let mut key = to;
|
||||
// Each step moves to an earlier key; more steps than keys is a loop.
|
||||
for _ in 0..=keys.len() {
|
||||
if key == from {
|
||||
return true;
|
||||
}
|
||||
let Some(e) = keys.iter().find(|k| k.key == key).and_then(|k| k.endorsement.as_ref()) else {
|
||||
return false;
|
||||
};
|
||||
let endorser_valid = keys.iter().any(|k| k.key == e.key && !k.revoked);
|
||||
if !endorser_valid || verify_ed25519(&e.key, &endorsement_message(publisher, key), &e.signature) != Ok(true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
key = &e.key;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Makes `roots` (base64) the keys `publisher` signs with, whatever this
|
||||
/// index says: another key it lists stays only when a root endorsed it (a
|
||||
/// rotation the roots vouched for). Revocations of the keys are kept.
|
||||
pub fn pin_publisher(&mut self, publisher: &str, name: &str, roots: &[String]) {
|
||||
if roots.is_empty() {
|
||||
return;
|
||||
}
|
||||
let i = match self.publishers.iter().position(|p| p.id == publisher) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
self.publishers.push(Publisher { id: publisher.into(), name: name.into(), keys: Vec::new() });
|
||||
self.publishers.len() - 1
|
||||
}
|
||||
};
|
||||
for root in roots {
|
||||
if !self.publishers[i].keys.iter().any(|k| &k.key == root) {
|
||||
self.publishers[i].keys.push(KeyEntry::new(root.clone()));
|
||||
}
|
||||
}
|
||||
let keys = self.publishers[i].keys.clone();
|
||||
let trusted = keys
|
||||
.into_iter()
|
||||
.filter(|k| roots.iter().any(|r| self.succeeds(publisher, r, &k.key)))
|
||||
.map(|k| if roots.contains(&k.key) { KeyEntry { endorsement: None, ..k } } else { k })
|
||||
.collect();
|
||||
self.publishers[i].keys = trusted;
|
||||
}
|
||||
|
||||
/// Replaces what this index lists for every publisher but `keep` with what
|
||||
/// `other` lists: its publishers, their plugins and their revocations
|
||||
/// (`other`'s downloads should already be absolute). `other` may only
|
||||
/// speak for its own publishers, and none of them may be in `keep`.
|
||||
pub fn include(&mut self, other: Index, keep: &[&str]) -> Result<(), String> {
|
||||
let mut theirs: Vec<&str> = Vec::new();
|
||||
for p in &other.publishers {
|
||||
if !publisher_id_ok(&p.id) {
|
||||
return Err(format!("invalid publisher id {:?}: use a-z, 0-9, '-' and '_'", p.id));
|
||||
}
|
||||
if keep.contains(&p.id.as_str()) {
|
||||
return Err(format!("may not list publisher {:?}", p.id));
|
||||
}
|
||||
if theirs.contains(&p.id.as_str()) {
|
||||
return Err(format!("publisher {:?} is listed twice", p.id));
|
||||
}
|
||||
theirs.push(&p.id);
|
||||
}
|
||||
let owned_by = |id: &str| theirs.iter().any(|p| owns(p, id));
|
||||
for e in &other.plugins {
|
||||
if !theirs.contains(&e.publisher.as_str()) || !owns(&e.publisher, &e.id) {
|
||||
return Err(format!("{}: not a plugin of publisher {:?}", e.id, e.publisher));
|
||||
}
|
||||
if semver::VersionReq::parse(&e.engine).is_err() {
|
||||
return Err(format!("{} {}: `engine` must be the plugin API it needs", e.id, e.version));
|
||||
}
|
||||
}
|
||||
if let Some(r) = other.revoked.iter().find(|r| !owned_by(&r.id)) {
|
||||
return Err(format!("{}: revoked by an index that doesn't publish it", r.id));
|
||||
}
|
||||
let kept = |id: &str| keep.iter().any(|p| owns(p, id));
|
||||
self.publishers.retain(|p| keep.contains(&p.id.as_str()));
|
||||
self.plugins.retain(|e| keep.contains(&e.publisher.as_str()));
|
||||
self.revoked.retain(|r| kept(&r.id));
|
||||
self.publishers.extend(other.publishers);
|
||||
self.plugins.extend(other.plugins);
|
||||
self.revoked.extend(other.revoked);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a package signed `new` (`None`: unsigned) may replace an installed
|
||||
/// one signed `installed` without asking the user: signed with the same key,
|
||||
/// or with one the installed key endorsed (per `index`). An unsigned installed
|
||||
/// copy pins nothing.
|
||||
pub fn signer_continues(
|
||||
index: Option<&Index>,
|
||||
installed: Option<&PackageSignature>,
|
||||
new: Option<&PackageSignature>,
|
||||
) -> bool {
|
||||
match (installed, new) {
|
||||
(None, _) => true,
|
||||
(Some(_), None) => false,
|
||||
(Some(old), Some(new)) => {
|
||||
old.publisher == new.publisher
|
||||
&& (old.key == new.key || index.is_some_and(|i| i.succeeds(&new.publisher, &old.key, &new.key)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the plugin `id` is in `publisher`'s namespace.
|
||||
fn owns(publisher: &str, id: &str) -> bool {
|
||||
id.strip_prefix(publisher).is_some_and(|rest| rest.starts_with('.'))
|
||||
}
|
||||
|
||||
/// A publisher id is a single segment of a plugin id.
|
||||
pub fn publisher_id_ok(id: &str) -> bool {
|
||||
!id.contains('.') && crate::validate_plugin_id(id).is_ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -354,7 +613,7 @@ mod tests {
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: false }],
|
||||
keys: vec![KeyEntry::new(key.public_base64())],
|
||||
}],
|
||||
plugins: Vec::new(),
|
||||
revoked: vec![Revocation {
|
||||
@@ -362,6 +621,7 @@ mod tests {
|
||||
versions: vec!["1.0.0".into()],
|
||||
reason: "malware".into(),
|
||||
}],
|
||||
moved: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -399,6 +659,138 @@ mod tests {
|
||||
assert_eq!(PublisherKey::from_base64(&key.seed_base64()).unwrap().public_base64(), key.public_base64());
|
||||
}
|
||||
|
||||
fn entry(id: &str, publisher: &str) -> IndexEntry {
|
||||
IndexEntry {
|
||||
id: id.into(),
|
||||
name: id.into(),
|
||||
version: "1.0.0".into(),
|
||||
description: String::new(),
|
||||
authors: Vec::new(),
|
||||
publisher: publisher.into(),
|
||||
download: format!("https://example.com/{id}.oiplugin"),
|
||||
digest: "d".into(),
|
||||
capabilities: Vec::new(),
|
||||
engine: "^1.0".into(),
|
||||
categories: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn version(id: &str, version: &str, engine: &str) -> IndexEntry {
|
||||
IndexEntry { version: version.into(), engine: engine.into(), ..entry(id, "acme") }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ide_is_offered_the_newest_version_it_can_load() {
|
||||
let host = crate::ENGINE_VERSION;
|
||||
let newer = semver::Version::parse(host).unwrap().major + 1;
|
||||
let mut index = Index {
|
||||
format: INDEX_FORMAT,
|
||||
plugins: vec![
|
||||
version("acme.a", "1.0.0", &format!("^{host}")),
|
||||
version("acme.a", "1.2.0", &format!("^{host}")),
|
||||
version("acme.a", "2.0.0", &format!("^{newer}.0")),
|
||||
version("acme.b", "0.1.0", &format!("^{newer}.0")),
|
||||
version("acme.b", "0.2.0", &format!("^{newer}.1")),
|
||||
version("acme.c", "1.0.0", ""),
|
||||
version("acme.c", "1.1.0", ""),
|
||||
],
|
||||
revoked: vec![Revocation { id: "acme.c".into(), versions: vec!["1.1.0".into()], reason: "r".into() }],
|
||||
..Index::default()
|
||||
};
|
||||
let offered = |index: &Index| {
|
||||
index.offered().into_iter().map(|(e, ok)| (e.id.clone(), e.version.clone(), ok)).collect::<Vec<_>>()
|
||||
};
|
||||
let expected = [
|
||||
("acme.a".to_owned(), "1.2.0".to_owned(), true),
|
||||
("acme.b".to_owned(), "0.2.0".to_owned(), false),
|
||||
// No `engine`: listed, but no IDE installs it.
|
||||
("acme.c".to_owned(), "1.0.0".to_owned(), false),
|
||||
];
|
||||
assert_eq!(offered(&index), expected);
|
||||
|
||||
// Pruning keeps the newest version per requirement, which changes nothing offered.
|
||||
index.prune_versions();
|
||||
let left: Vec<_> = index.plugins.iter().map(|e| format!("{} {}", e.id, e.version)).collect();
|
||||
assert_eq!(left, ["acme.a 1.2.0", "acme.a 2.0.0", "acme.b 0.1.0", "acme.b 0.2.0", "acme.c 1.0.0"]);
|
||||
assert_eq!(offered(&index), expected);
|
||||
|
||||
let built_for = semver::Version::new(1, 3, 0);
|
||||
assert_eq!(engine_requirement("^1.0", Some(&built_for)), "^1.0, ^1.3");
|
||||
assert_eq!(engine_requirement("^1.0", None), "^1.0");
|
||||
assert!(semver::VersionReq::parse(&engine_requirement("^1.0", Some(&built_for))).is_ok());
|
||||
}
|
||||
|
||||
fn publisher(id: &str) -> Publisher {
|
||||
Publisher { id: id.into(), name: id.into(), keys: vec![KeyEntry::new(format!("{id}-key"))] }
|
||||
}
|
||||
|
||||
fn revocation(id: &str) -> Revocation {
|
||||
Revocation { id: id.into(), versions: Vec::new(), reason: "r".into() }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn including_an_index_replaces_everything_but_the_kept_publishers() {
|
||||
let mut official = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("openidea"), publisher("gone")],
|
||||
plugins: vec![entry("openidea.a", "openidea"), entry("gone.x", "gone")],
|
||||
revoked: vec![revocation("openidea.old"), revocation("gone.x")],
|
||||
moved: None,
|
||||
};
|
||||
let community = Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers: vec![publisher("alice")],
|
||||
plugins: vec![entry("alice.arthas", "alice")],
|
||||
revoked: vec![revocation("alice.bad")],
|
||||
moved: None,
|
||||
};
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
let ids = |v: &[IndexEntry]| v.iter().map(|e| e.id.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(official.publishers.iter().map(|p| p.id.as_str()).collect::<Vec<_>>(), ["openidea", "alice"]);
|
||||
assert_eq!(ids(&official.plugins), ["openidea.a", "alice.arthas"]);
|
||||
assert_eq!(official.revoked.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(), ["openidea.old", "alice.bad"]);
|
||||
|
||||
// Including again is idempotent.
|
||||
let once = official.clone();
|
||||
official.include(community.clone(), &["openidea"]).unwrap();
|
||||
assert_eq!(official, once);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_included_index_speaks_only_for_its_own_publishers() {
|
||||
let base = Index { format: INDEX_FORMAT, publishers: vec![publisher("openidea")], ..Index::default() };
|
||||
let with = |publishers: Vec<Publisher>, plugins: Vec<IndexEntry>, revoked: Vec<Revocation>| Index {
|
||||
format: INDEX_FORMAT,
|
||||
publishers,
|
||||
plugins,
|
||||
revoked,
|
||||
moved: None,
|
||||
};
|
||||
let cases = [
|
||||
(with(vec![publisher("openidea")], vec![], vec![]), "may not list"),
|
||||
(with(vec![publisher("alice"), publisher("alice")], vec![], vec![]), "listed twice"),
|
||||
(with(vec![publisher("openidea.x")], vec![], vec![]), "invalid publisher"),
|
||||
(with(vec![publisher("alice")], vec![entry("openidea.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alice.a", "bob")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![entry("alicex.a", "alice")], vec![]), "not a plugin of"),
|
||||
(with(vec![publisher("alice")], vec![], vec![revocation("openidea.a")]), "doesn't publish"),
|
||||
(
|
||||
with(
|
||||
vec![publisher("alice")],
|
||||
vec![IndexEntry { engine: String::new(), ..entry("alice.a", "alice") }],
|
||||
vec![],
|
||||
),
|
||||
"`engine`",
|
||||
),
|
||||
];
|
||||
for (other, error) in cases {
|
||||
let mut index = base.clone();
|
||||
let err = index.include(other, &["openidea"]).unwrap_err();
|
||||
assert!(err.contains(error), "{err}");
|
||||
assert_eq!(index, base);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpacking_refuses_paths_leaving_the_package() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
@@ -413,7 +805,7 @@ mod tests {
|
||||
let err = unpack(&out, &tmp.path().join("dest")).unwrap_err();
|
||||
assert!(err.contains("invalid path"), "{err}");
|
||||
assert!(!tmp.path().join("outside.txt").exists());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).is_err());
|
||||
assert!(Index::parse(r#"{"format": 2}"#).unwrap().is_newer());
|
||||
}
|
||||
|
||||
fn zip_of(path: &Path, entries: &[(&str, &str)]) {
|
||||
@@ -462,4 +854,102 @@ mod tests {
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{name}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_newer_index_still_revokes_and_moves() {
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": [{{"anything": "new"}}], "shape": "changed",
|
||||
"publishers": [{{"id": "acme", "name": "ACME", "keys": [{{"key": "k1", "revoked": true}}]}}, {{"id": 5}}],
|
||||
"revoked": [{{"id": "acme.bad", "reason": "malware"}}, {{"oops": 1}}],
|
||||
"moved": "https://example.com/next.json"}}"#,
|
||||
INDEX_FORMAT + 1
|
||||
);
|
||||
let index = Index::parse(&newer).unwrap();
|
||||
assert!(index.is_newer());
|
||||
assert!(index.plugins.is_empty() && index.offered().is_empty());
|
||||
assert_eq!(index.revocation("acme.bad", "2.0.0").unwrap().reason, "malware");
|
||||
assert!(index.key_revoked("k1"));
|
||||
assert_eq!(index.moved.as_deref(), Some("https://example.com/next.json"));
|
||||
// Fields this IDE doesn't know are ignored in its own format.
|
||||
let current = format!(r#"{{"format": {INDEX_FORMAT}, "later": true, "plugins": []}}"#);
|
||||
assert!(!Index::parse(¤t).unwrap().is_newer());
|
||||
assert!(Index::parse(r#"{"format": 0}"#).unwrap_err().contains("not supported"));
|
||||
assert!(Index::parse(r#"{"plugins": []}"#).is_err());
|
||||
}
|
||||
|
||||
fn keys<'a>(index: &'a mut Index, publisher: &str) -> &'a mut Vec<KeyEntry> {
|
||||
&mut index.publishers.iter_mut().find(|p| p.id == publisher).unwrap().keys
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_keys_continue_through_endorsements() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let mut index = index(&k1);
|
||||
let endorsed = |by: &PublisherKey, key: &PublisherKey| KeyEntry {
|
||||
endorsement: Some(by.endorse("acme", &key.public_base64())),
|
||||
..KeyEntry::new(key.public_base64())
|
||||
};
|
||||
keys(&mut index, "acme").push(endorsed(&k1, &k2));
|
||||
keys(&mut index, "acme").push(endorsed(&k2, &k3));
|
||||
let (p1, p2, p3) = (k1.public_base64(), k2.public_base64(), k3.public_base64());
|
||||
assert!(index.succeeds("acme", &p1, &p1));
|
||||
assert!(index.succeeds("acme", &p1, &p3), "through k2");
|
||||
assert!(!index.succeeds("acme", &p3, &p1), "not backwards");
|
||||
assert!(!index.succeeds("other", &p1, &p2));
|
||||
// An endorsement for another publisher, or by a revoked key, doesn't count.
|
||||
let mut foreign = index.clone();
|
||||
keys(&mut foreign, "acme")[1].endorsement = Some(k1.endorse("evil", &p2));
|
||||
assert!(!foreign.succeeds("acme", &p1, &p2));
|
||||
let mut stolen = index.clone();
|
||||
keys(&mut stolen, "acme")[0].revoked = true;
|
||||
assert!(!stolen.succeeds("acme", &p1, &p2));
|
||||
|
||||
let sig = |key: &PublisherKey| key.sign("acme", "acme.tools", "1.0.0", "d");
|
||||
assert!(signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&k3))));
|
||||
assert!(signer_continues(None, Some(&sig(&k1)), Some(&sig(&k1))));
|
||||
assert!(!signer_continues(None, Some(&sig(&k1)), Some(&sig(&k2))), "no index to vouch");
|
||||
let stranger = PublisherKey::from_seed([4; 32]);
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), Some(&sig(&stranger))));
|
||||
assert!(!signer_continues(Some(&index), Some(&sig(&k1)), None), "signed, then unsigned");
|
||||
assert!(signer_continues(Some(&index), None, Some(&sig(&stranger))), "unsigned pins nothing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pinned_publishers_keep_their_built_in_keys() {
|
||||
let (root, next, intruder) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([5; 32]));
|
||||
let mut index = index(&intruder);
|
||||
index.publishers[0].id = "openidea".into();
|
||||
keys(&mut index, "openidea").push(KeyEntry {
|
||||
endorsement: Some(root.endorse("openidea", &next.public_base64())),
|
||||
..KeyEntry::new(next.public_base64())
|
||||
});
|
||||
let roots = [root.public_base64()];
|
||||
let listed = |index: &Index| -> Vec<(String, bool)> {
|
||||
let mut index = index.clone();
|
||||
index.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
keys(&mut index, "openidea").iter().map(|k| (k.key.clone(), k.revoked)).collect()
|
||||
};
|
||||
// The intruder's key goes, the successor the root endorsed stays, the root is added.
|
||||
assert_eq!(listed(&index), [(next.public_base64(), false), (root.public_base64(), false)]);
|
||||
let mut pinned = index.clone();
|
||||
pinned.pin_publisher("openidea", "OpenIDEA", &roots);
|
||||
let digest = "d";
|
||||
let signed = |k: &PublisherKey| k.sign("openidea", "openidea.tools", "1.0.0", digest);
|
||||
assert!(pinned.verify(&signed(&intruder), "openidea.tools", "1.0.0", digest).is_err());
|
||||
assert!(pinned.verify(&signed(&next), "openidea.tools", "1.0.0", digest).is_ok());
|
||||
// A revoked root stays revoked, and what it endorsed is no longer trusted.
|
||||
keys(&mut index, "openidea").push(KeyEntry { revoked: true, ..KeyEntry::new(root.public_base64()) });
|
||||
assert_eq!(listed(&index), [(root.public_base64(), true)]);
|
||||
|
||||
// A publisher missing from the index is added with its roots.
|
||||
let mut empty = Index { format: INDEX_FORMAT, ..Index::default() };
|
||||
empty.pin_publisher("openidea", "OpenIDEA", &[root.public_base64()]);
|
||||
assert_eq!(empty.verify(&signed(&root), "openidea.tools", "1.0.0", digest).unwrap(), "openidea");
|
||||
// No roots: the index decides.
|
||||
let mut open = index.clone();
|
||||
open.pin_publisher("openidea", "OpenIDEA", &[]);
|
||||
assert_eq!(open, index);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! The signed-in GitHub account, independent of any project: its repositories for
|
||||
//! "Get from Version Control" › GitHub.
|
||||
|
||||
use crate::{GhAvailability, QUICK, classify_failure, command, gh_executable, process};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// A repository of the signed-in account (owned, collaborator, or organization member).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhAccountRepo {
|
||||
/// `owner/name`.
|
||||
pub name_with_owner: String,
|
||||
pub description: Option<String>,
|
||||
pub private: bool,
|
||||
pub fork: bool,
|
||||
pub archived: bool,
|
||||
/// What `git clone` takes: HTTPS, or SSH when gh's `git_protocol` is `ssh`.
|
||||
pub clone_url: String,
|
||||
}
|
||||
|
||||
/// The account's repositories, or why they can't be listed.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum GhAccountRepos {
|
||||
Ready {
|
||||
login: String,
|
||||
repos: Vec<GhAccountRepo>,
|
||||
},
|
||||
/// No `gh` executable was found.
|
||||
NotInstalled,
|
||||
/// `gh auth login` has not been run (or the token expired).
|
||||
NotLoggedIn {
|
||||
message: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RestRepo {
|
||||
full_name: String,
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
private: bool,
|
||||
#[serde(default)]
|
||||
fork: bool,
|
||||
#[serde(default)]
|
||||
archived: bool,
|
||||
clone_url: String,
|
||||
ssh_url: String,
|
||||
}
|
||||
|
||||
/// `gh api --paginate --slurp user/repos` output: one array per page.
|
||||
pub fn parse_account_repos(slurped: &str, ssh: bool) -> Result<Vec<GhAccountRepo>, serde_json::Error> {
|
||||
let pages: Vec<Vec<RestRepo>> = serde_json::from_str(slurped)?;
|
||||
Ok(pages
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|r| GhAccountRepo {
|
||||
name_with_owner: r.full_name,
|
||||
description: r.description.filter(|d| !d.trim().is_empty()),
|
||||
private: r.private,
|
||||
fork: r.fork,
|
||||
archived: r.archived,
|
||||
clone_url: if ssh { r.ssh_url } else { r.clone_url },
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lists the repositories of the account gh is signed in to, most recently pushed first.
|
||||
pub fn account_repositories() -> GhAccountRepos {
|
||||
if gh_executable().is_none() {
|
||||
return GhAccountRepos::NotInstalled;
|
||||
}
|
||||
let dir = std::env::temp_dir();
|
||||
let gh = |args: &[&str]| -> Result<String, GhAccountRepos> {
|
||||
let out = command(&dir, args)
|
||||
.and_then(|cmd| process::run(cmd, QUICK, None))
|
||||
.map_err(|e| GhAccountRepos::Error { message: e.message })?;
|
||||
if out.success {
|
||||
return Ok(out.stdout);
|
||||
}
|
||||
let message = if out.stderr.trim().is_empty() { out.stdout } else { out.stderr };
|
||||
Err(match classify_failure(&message) {
|
||||
GhAvailability::NotLoggedIn { message } => GhAccountRepos::NotLoggedIn { message },
|
||||
_ => GhAccountRepos::Error { message: message.trim().to_owned() },
|
||||
})
|
||||
};
|
||||
let result = (|| {
|
||||
let login = gh(&["api", "user", "--jq", ".login"])?.trim().to_owned();
|
||||
let ssh = gh(&["config", "get", "git_protocol", "--host", "github.com"]).is_ok_and(|p| p.trim() == "ssh");
|
||||
let pages = gh(&[
|
||||
"api",
|
||||
"user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member",
|
||||
"--paginate",
|
||||
"--slurp",
|
||||
])?;
|
||||
let repos = parse_account_repos(&pages, ssh)
|
||||
.map_err(|e| GhAccountRepos::Error { message: format!("unexpected gh output: {e}") })?;
|
||||
Ok(GhAccountRepos::Ready { login, repos })
|
||||
})();
|
||||
result.unwrap_or_else(|e| e)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reads_every_page_and_picks_the_configured_protocol() {
|
||||
let pages = r#"[
|
||||
[{"full_name":"me/app","description":"An app","private":true,"fork":false,"archived":false,
|
||||
"clone_url":"https://github.com/me/app.git","ssh_url":"[email protected]:me/app.git"}],
|
||||
[{"full_name":"org/lib","description":"","fork":true,
|
||||
"clone_url":"https://github.com/org/lib.git","ssh_url":"[email protected]:org/lib.git"}]
|
||||
]"#;
|
||||
let https = parse_account_repos(pages, false).unwrap();
|
||||
assert_eq!(
|
||||
https,
|
||||
vec![
|
||||
GhAccountRepo {
|
||||
name_with_owner: "me/app".into(),
|
||||
description: Some("An app".into()),
|
||||
private: true,
|
||||
fork: false,
|
||||
archived: false,
|
||||
clone_url: "https://github.com/me/app.git".into(),
|
||||
},
|
||||
GhAccountRepo {
|
||||
name_with_owner: "org/lib".into(),
|
||||
description: None,
|
||||
private: false,
|
||||
fork: true,
|
||||
archived: false,
|
||||
clone_url: "https://github.com/org/lib.git".into(),
|
||||
},
|
||||
]
|
||||
);
|
||||
let ssh = parse_account_repos(pages, true).unwrap();
|
||||
assert_eq!(ssh[1].clone_url, "[email protected]:org/lib.git");
|
||||
assert!(parse_account_repos("[]", false).unwrap().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,11 @@
|
||||
//!
|
||||
//! Every function blocks; callers run them off the UI / command threads.
|
||||
|
||||
mod account;
|
||||
mod model;
|
||||
mod process;
|
||||
|
||||
pub use account::{GhAccountRepo, GhAccountRepos, account_repositories, parse_account_repos};
|
||||
pub use model::*;
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
|
||||
@@ -14,6 +14,7 @@ encoding_rs.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-index.workspace = true
|
||||
@@ -44,6 +45,8 @@ ide-notebook.workspace = true
|
||||
ide-extension.workspace = true
|
||||
ide-plugin.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
ide-docker = { path = "../ide-docker" }
|
||||
semver = "1"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
toml.workspace = true
|
||||
@@ -57,12 +60,13 @@ dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
uuid.workspace = true
|
||||
tempfile.workspace = true
|
||||
zip.workspace = true
|
||||
quick-xml = "0.42"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util", "test-util"] }
|
||||
criterion = { version = "0.5", default-features = false }
|
||||
zip.workspace = true
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
rustix = { version = "1.1", features = ["fs", "process"] }
|
||||
|
||||
@@ -3,13 +3,15 @@
|
||||
//! ```text
|
||||
//! openidea-plugin new <dir> [--template command|theme|icon-theme|language] [--id ID] [--name NAME] [--sdk PATH]
|
||||
//! openidea-plugin build [dir] [--debug] # cargo → dist/plugin.wasm, then `check`
|
||||
//! openidea-plugin check [dir] # validate the package as the IDE would install it
|
||||
//! openidea-plugin check [dir|package] # validate the package as the IDE would install it
|
||||
//! openidea-plugin link [dir] # load the folder in the IDE without installing it
|
||||
//! openidea-plugin unlink [dir]
|
||||
//! openidea-plugin links # list linked folders
|
||||
//! openidea-plugin keygen <key-file> # a publisher's Ed25519 key
|
||||
//! openidea-plugin endorse <new-public-key> --key OLD --publisher ID # vouch for a new key
|
||||
//! openidea-plugin pack [dir] [--key F --publisher ID] [--out FILE] # <id>-<version>.oiplugin
|
||||
//! openidea-plugin index <dir> --publisher ID --name NAME --key F # marketplace index.json
|
||||
//! openidea-plugin index <dir> [--publisher ID --name NAME --key F [--previous-key F]] # marketplace index.json
|
||||
//! openidea-plugin merge <index> <other-index> --keep ID # list another index's publishers
|
||||
//! ```
|
||||
//!
|
||||
//! `link` writes `links.json` in the IDE's plugins directory
|
||||
@@ -20,7 +22,7 @@
|
||||
mod templates;
|
||||
|
||||
use ide_extension::{Manifest, validate_plugin_id};
|
||||
use ide_host::plugins::PluginHost;
|
||||
use ide_host::plugins::{PluginHost, read_market_url, resolve_market_href};
|
||||
use ide_plugin::{Limits, PluginEngine};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, ExitCode};
|
||||
@@ -33,15 +35,27 @@ commands:
|
||||
new <dir> [--template T] [--id ID] [--name NAME] [--sdk PATH]
|
||||
create a plugin; T = command (default), theme, icon-theme, language
|
||||
build [dir] [--debug] compile a Rust plugin to dist/plugin.wasm and check it
|
||||
check [dir] validate the package as the IDE would install it
|
||||
check [dir|package] validate the package (folder or .oiplugin) as the IDE would install it
|
||||
link [dir] load the folder in the IDE without installing it
|
||||
unlink [dir] stop loading a linked folder
|
||||
links list linked folders
|
||||
keygen <key-file> create a publisher signing key (keep the file secret)
|
||||
endorse <new-public-key> --key F --publisher ID
|
||||
print an earlier key's endorsement of a new one (JSON), for an
|
||||
index the publisher doesn't write with `index --previous-key`
|
||||
pack [dir] [--key F --publisher ID] [--out FILE]
|
||||
write <id>-<version>.oiplugin, signed with the key
|
||||
index <dir> --publisher ID --name NAME --key F [--out FILE]
|
||||
write the marketplace index.json for the packages in dir
|
||||
index <dir> [--publisher ID --name NAME --key F [--previous-key F]] [--out FILE]
|
||||
[--download-base URL]
|
||||
write the marketplace index.json for the packages in dir: with a
|
||||
key, register it and list that publisher's plugins (earlier
|
||||
versions stay for older IDEs); without, list only these packages,
|
||||
each checked against the index's publishers. A new key replacing
|
||||
an earlier one is endorsed with --previous-key, so installed
|
||||
plugins update to it without asking
|
||||
merge <index> <other-index> --keep ID [--out FILE]
|
||||
replace every publisher but ID in index with those of other-index
|
||||
(a path or URL), their plugins and revocations
|
||||
|
||||
environment:
|
||||
OPENIDEA_PLUGINS_DIR the IDE's plugins directory (default: the app data directory)";
|
||||
@@ -51,13 +65,15 @@ fn main() -> ExitCode {
|
||||
let result = match args.first().map(String::as_str) {
|
||||
Some("new") => cmd_new(&args[1..]),
|
||||
Some("build") => cmd_build(&args[1..]),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check(&d)),
|
||||
Some("check") => dir_arg(&args[1..]).and_then(|d| check_path(&d)),
|
||||
Some("link") => dir_arg(&args[1..]).and_then(|d| link(&d, true)),
|
||||
Some("unlink") => dir_arg(&args[1..]).and_then(|d| link(&d, false)),
|
||||
Some("links") => list_links(),
|
||||
Some("keygen") => cmd_keygen(&args[1..]),
|
||||
Some("endorse") => cmd_endorse(&args[1..]),
|
||||
Some("pack") => cmd_pack(&args[1..]),
|
||||
Some("index") => cmd_index(&args[1..]),
|
||||
Some("merge") => cmd_merge(&args[1..]),
|
||||
Some("--version" | "-V") => {
|
||||
println!("openidea-plugin {} (plugin API {})", env!("CARGO_PKG_VERSION"), ide_extension::ENGINE_VERSION);
|
||||
Ok(())
|
||||
@@ -323,10 +339,21 @@ fn ensure_wasm_target() {
|
||||
|
||||
/// Validates `dir` exactly as Install from Disk would, and checks the
|
||||
/// component imports only the plugin API.
|
||||
/// [`check`] of a folder, or of a package file unpacked as installing would.
|
||||
fn check_path(path: &Path) -> Result<(), String> {
|
||||
if !path.is_file() {
|
||||
return check(path);
|
||||
}
|
||||
let tmp = scratch_dir("unpack")?;
|
||||
let dir = tmp.path().join("package");
|
||||
package::unpack(path, &dir).and_then(|_| check(&dir))
|
||||
}
|
||||
|
||||
fn check(dir: &Path) -> Result<(), String> {
|
||||
let dir = canonical(dir)?;
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-check-{}", std::process::id()));
|
||||
let result = (|| {
|
||||
let tmp = scratch_dir("check")?;
|
||||
let scratch = tmp.path().join("host");
|
||||
(|| {
|
||||
let host = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
let preview = host.preview_install(&dir).map_err(|e| e.to_string())?;
|
||||
@@ -345,6 +372,17 @@ fn check(dir: &Path) -> Result<(), String> {
|
||||
} else {
|
||||
println!(" code: none (contributions only)");
|
||||
}
|
||||
println!(" categories: {}", manifest.categories().join(", "));
|
||||
for c in manifest.unknown_categories() {
|
||||
println!(" warning: unknown category {c:?} (ignored); use {}", ide_extension::CATEGORIES.join(", "));
|
||||
}
|
||||
if manifest.declared_categories.len() > ide_extension::MAX_CATEGORIES {
|
||||
println!(
|
||||
" warning: only the first {} known categories are used; {} are declared",
|
||||
ide_extension::MAX_CATEGORIES,
|
||||
manifest.declared_categories.len()
|
||||
);
|
||||
}
|
||||
if !manifest.activation.is_empty() {
|
||||
let events: Vec<String> = manifest.activation.iter().map(ToString::to_string).collect();
|
||||
println!(" activation: {}", events.join(", "));
|
||||
@@ -361,9 +399,7 @@ fn check(dir: &Path) -> Result<(), String> {
|
||||
}
|
||||
println!("ok");
|
||||
Ok(())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
})()
|
||||
}
|
||||
|
||||
fn human_size(bytes: u64) -> String {
|
||||
@@ -479,14 +515,31 @@ fn cmd_keygen(args: &[String]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_endorse(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["key", "publisher"])?;
|
||||
let usage = "usage: openidea-plugin endorse <new-public-key> --key <earlier-key-file> --publisher ID";
|
||||
let ([new], Some(key), Some(publisher)) = (a.positional.as_slice(), a.value("key"), a.value("publisher")) else {
|
||||
return Err(usage.into());
|
||||
};
|
||||
if !package::publisher_id_ok(publisher) {
|
||||
return Err(format!("invalid publisher id {publisher:?}"));
|
||||
}
|
||||
let endorsement = read_key(key)?.endorse(publisher, new.trim());
|
||||
println!("{}", serde_json::to_string_pretty(&endorsement).map_err(|e| e.to_string())?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A fresh scratch directory, removed when dropped.
|
||||
fn scratch_dir(what: &str) -> Result<tempfile::TempDir, String> {
|
||||
tempfile::Builder::new().prefix(&format!("openidea-plugin-{what}-")).tempdir().map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// A scratch host to list a package's files as the IDE would.
|
||||
fn with_scratch_host<T>(f: impl FnOnce(&PluginHost) -> Result<T, String>) -> Result<T, String> {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-pack-{}", std::process::id()));
|
||||
let result = PluginHost::new(scratch.clone(), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
let tmp = scratch_dir("host")?;
|
||||
PluginHost::new(tmp.path().join("host"), Arc::new(|_: Option<&str>, _| {}), Limits::default())
|
||||
.map_err(|e| e.to_string())
|
||||
.and_then(|h| f(&h));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
result
|
||||
.and_then(|h| f(&h))
|
||||
}
|
||||
|
||||
fn cmd_pack(args: &[String]) -> Result<(), String> {
|
||||
@@ -522,29 +575,28 @@ fn cmd_pack(args: &[String]) -> Result<(), String> {
|
||||
}
|
||||
|
||||
fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["publisher", "name", "key", "out"])?;
|
||||
let a = Args::parse(args, &[], &["publisher", "name", "key", "previous-key", "out", "download-base"])?;
|
||||
let dir = canonical(&a.dir()?)?;
|
||||
let publisher = a.value("publisher").ok_or("--publisher is required")?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let key = read_key(a.value("key").ok_or("--key is required")?)?;
|
||||
let out = a.value("out").map(PathBuf::from).unwrap_or_else(|| dir.join("index.json"));
|
||||
let base = a.value("download-base").map(|b| b.trim_end_matches('/'));
|
||||
// Revocations and other publishers of an existing index are kept.
|
||||
let mut index = match std::fs::read_to_string(&out) {
|
||||
Ok(text) => Index::parse(&text)?,
|
||||
Ok(text) => current_format(Index::parse(&text)?, &out.to_string_lossy())?,
|
||||
Err(_) => Index { format: package::INDEX_FORMAT, ..Index::default() },
|
||||
};
|
||||
// Earlier keys of the publisher stay listed (packages they signed remain valid until revoked).
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = index
|
||||
.publishers
|
||||
.iter()
|
||||
.filter(|p| p.id == publisher)
|
||||
.flat_map(|p| p.keys.iter().filter(|k| k.key != current).cloned())
|
||||
.collect();
|
||||
index.publishers.retain(|p| p.id != publisher);
|
||||
let keys: Vec<KeyEntry> = std::iter::once(KeyEntry { key: current, revoked: false }).chain(earlier).collect();
|
||||
index.publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
index.plugins.clear();
|
||||
let publisher = match (a.value("publisher"), a.value("key")) {
|
||||
(Some(publisher), Some(key)) => {
|
||||
let key = read_key(key)?;
|
||||
let name = a.value("name").unwrap_or(publisher);
|
||||
let previous = a.value("previous-key").map(read_key).transpose()?;
|
||||
index.publishers =
|
||||
register_key(std::mem::take(&mut index.publishers), publisher, name, &key, previous.as_ref())?;
|
||||
Some(publisher)
|
||||
}
|
||||
(None, None) if a.has("previous-key") => return Err("--previous-key goes with --publisher and --key".into()),
|
||||
(None, None) => None,
|
||||
_ => return Err("--publisher and --key go together".into()),
|
||||
};
|
||||
let mut packages: Vec<PathBuf> = std::fs::read_dir(&dir)
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten()
|
||||
@@ -552,35 +604,144 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
.filter(|p| p.extension().is_some_and(|e| e == package::PACKAGE_EXTENSION))
|
||||
.collect();
|
||||
packages.sort();
|
||||
let engine = PluginEngine::new()?;
|
||||
let mut added: Vec<IndexEntry> = Vec::new();
|
||||
for path in packages {
|
||||
let scratch = std::env::temp_dir().join(format!("openidea-plugin-index-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let unpacked = package::unpack(&path, &scratch)?;
|
||||
let tmp = scratch_dir("index")?;
|
||||
let scratch = tmp.path().join("package");
|
||||
let entry = (|| {
|
||||
let unpacked = package::unpack(&path, &scratch)?;
|
||||
let m = Manifest::load(&scratch)?;
|
||||
let files = with_scratch_host(|h| h.package_files_of(&scratch).map_err(|e| e.to_string()))?;
|
||||
let digest = package::package_digest(&scratch, &files).map_err(|e| e.to_string())?;
|
||||
let sig = unpacked.signature.as_ref().ok_or_else(|| format!("{}: not signed", path.display()))?;
|
||||
sig.check(&m.id, &m.version, &digest).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let sig = unpacked.signature.as_ref().ok_or("not signed")?;
|
||||
let signer = index.verify(sig, &m.id, &m.version, &digest)?;
|
||||
if publisher.is_some_and(|p| p != signer) {
|
||||
return Err(format!("signed by {signer}, not {}", publisher.unwrap_or_default()));
|
||||
}
|
||||
if added.iter().any(|e| (&e.id, &e.version) == (&m.id, &m.version)) {
|
||||
return Err(format!("another package of {} {} is in the folder", m.id, m.version));
|
||||
}
|
||||
if m.legacy {
|
||||
return Err("plugin.toml uses the 0.1 format; see plugins/README.md".into());
|
||||
}
|
||||
let built_for = match &m.wasm {
|
||||
Some(wasm) => engine.check_component(&engine.compile(&scratch.join(wasm))?)?,
|
||||
None => None,
|
||||
};
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
Ok::<_, String>(IndexEntry {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
version: m.version.clone(),
|
||||
description: m.description.clone(),
|
||||
authors: m.authors.clone(),
|
||||
publisher: sig.publisher.clone(),
|
||||
download: path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
publisher: signer,
|
||||
download: base.map_or_else(|| file.clone(), |b| format!("{b}/{file}")),
|
||||
digest,
|
||||
capabilities: m.capabilities.all().map(|c| c.to_string()).collect(),
|
||||
engine: package::engine_requirement(&m.engine, built_for.as_ref()),
|
||||
categories: m.categories(),
|
||||
})
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&scratch);
|
||||
let entry = entry?;
|
||||
println!(" {} {}", entry.id, entry.version);
|
||||
index.plugins.push(entry);
|
||||
let entry = entry.map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
println!(" {} {} (plugin API {})", entry.id, entry.version, entry.engine);
|
||||
added.push(entry);
|
||||
}
|
||||
let json = serde_json::to_string_pretty(&index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(&out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
// A publisher's earlier versions of these plugins stay for IDEs that can't load the new
|
||||
// ones (pruned below); plugins no longer in the folder go.
|
||||
index.plugins.retain(|e| match publisher {
|
||||
Some(p) => {
|
||||
e.publisher != p
|
||||
|| (added.iter().any(|n| n.id == e.id)
|
||||
&& !added.iter().any(|n| (&n.id, &n.version) == (&e.id, &e.version)))
|
||||
}
|
||||
None => false,
|
||||
});
|
||||
index.plugins.extend(added);
|
||||
index.prune_versions();
|
||||
write_index(&index, &out)
|
||||
}
|
||||
|
||||
/// `publishers` with `key` as `publisher`'s current key. Earlier keys stay listed
|
||||
/// (packages they signed remain valid until revoked); a key new to the
|
||||
/// publisher is endorsed by `previous`, one of them, so installed plugins
|
||||
/// signed with it update without asking.
|
||||
fn register_key(
|
||||
mut publishers: Vec<package::Publisher>,
|
||||
publisher: &str,
|
||||
name: &str,
|
||||
key: &PublisherKey,
|
||||
previous: Option<&PublisherKey>,
|
||||
) -> Result<Vec<package::Publisher>, String> {
|
||||
let current = key.public_base64();
|
||||
let earlier: Vec<KeyEntry> = publishers.iter().filter(|p| p.id == publisher).flat_map(|p| p.keys.clone()).collect();
|
||||
let entry = match earlier.iter().find(|k| k.key == current) {
|
||||
Some(listed) => listed.clone(),
|
||||
None => {
|
||||
let endorsement = match previous {
|
||||
Some(prev) => {
|
||||
let prev_key = prev.public_base64();
|
||||
if !earlier.iter().any(|k| k.key == prev_key && !k.revoked) {
|
||||
return Err(format!("--previous-key is not one of {publisher}'s valid keys in the index"));
|
||||
}
|
||||
Some(prev.endorse(publisher, ¤t))
|
||||
}
|
||||
None => {
|
||||
if earlier.iter().any(|k| !k.revoked) {
|
||||
println!(
|
||||
" warning: {publisher}'s new key is not endorsed (--previous-key): users who installed \
|
||||
its plugins will be asked to confirm the change when they update"
|
||||
);
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
KeyEntry { endorsement, ..KeyEntry::new(current.clone()) }
|
||||
}
|
||||
};
|
||||
let keys = std::iter::once(entry).chain(earlier.into_iter().filter(|k| k.key != current)).collect();
|
||||
publishers.retain(|p| p.id != publisher);
|
||||
publishers.push(package::Publisher { id: publisher.into(), name: name.into(), keys });
|
||||
Ok(publishers)
|
||||
}
|
||||
|
||||
/// `index`, unless a newer `openidea-plugin` wrote it (rewriting it would drop what this one can't read).
|
||||
fn current_format(index: Index, file: &str) -> Result<Index, String> {
|
||||
if index.is_newer() {
|
||||
return Err(format!(
|
||||
"{file}: index format {} is newer than this tool writes; update openidea-plugin",
|
||||
index.format
|
||||
));
|
||||
}
|
||||
Ok(index)
|
||||
}
|
||||
|
||||
fn cmd_merge(args: &[String]) -> Result<(), String> {
|
||||
let a = Args::parse(args, &[], &["keep", "out"])?;
|
||||
let [base, other] = a.positional.as_slice() else {
|
||||
return Err("usage: openidea-plugin merge <index> <other-index> --keep ID [--out FILE]".into());
|
||||
};
|
||||
let keep = a.value("keep").ok_or("--keep is required")?;
|
||||
let out = a.value("out").unwrap_or(base);
|
||||
let mut index =
|
||||
current_format(Index::parse(&std::fs::read_to_string(base).map_err(|e| format!("{base}: {e}"))?)?, base)?;
|
||||
let bytes = read_market_url(other, 16 << 20)?;
|
||||
let theirs = Index::parse(&String::from_utf8(bytes).map_err(|_| format!("{other}: not UTF-8"))?)?;
|
||||
let mut theirs = current_format(theirs, other)?;
|
||||
// Their downloads are relative to their index, not to ours.
|
||||
for e in &mut theirs.plugins {
|
||||
e.download = resolve_market_href(other, &e.download);
|
||||
}
|
||||
let count = theirs.plugins.len();
|
||||
index.include(theirs, &[keep]).map_err(|e| format!("{other}: {e}"))?;
|
||||
println!(" {count} plugins from {other}");
|
||||
write_index(&index, Path::new(out))
|
||||
}
|
||||
|
||||
fn write_index(index: &Index, out: &Path) -> Result<(), String> {
|
||||
let json = serde_json::to_string_pretty(index).map_err(|e| e.to_string())?;
|
||||
std::fs::write(out, json).map_err(|e| format!("{}: {e}", out.display()))?;
|
||||
println!("-> {} ({} plugins)", out.display(), index.plugins.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -589,6 +750,25 @@ fn cmd_index(args: &[String]) -> Result<(), String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_key_is_endorsed_by_the_previous_one() {
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let first = register_key(Vec::new(), "acme", "ACME", &k1, None).unwrap();
|
||||
let rotated = register_key(first.clone(), "acme", "ACME", &k2, Some(&k1)).unwrap();
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers: rotated.clone(), ..Index::default() };
|
||||
assert!(index.succeeds("acme", &k1.public_base64(), &k2.public_base64()));
|
||||
assert_eq!(rotated[0].keys.len(), 2, "the earlier key stays listed");
|
||||
// Registering the current key again keeps its endorsement.
|
||||
let again = register_key(rotated.clone(), "acme", "ACME", &k2, None).unwrap();
|
||||
assert_eq!(again, rotated);
|
||||
// Only a key the publisher has can endorse.
|
||||
assert!(register_key(first.clone(), "acme", "ACME", &k2, Some(&k3)).unwrap_err().contains("--previous-key"));
|
||||
// Without one the new key is listed unendorsed.
|
||||
let unendorsed = register_key(first, "acme", "ACME", &k3, None).unwrap();
|
||||
assert!(unendorsed[0].keys[0].endorsement.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names() {
|
||||
assert_eq!(slugify("My Cool_Plugin"), "my-cool-plugin");
|
||||
@@ -601,6 +781,126 @@ mod tests {
|
||||
assert_eq!(relative(Path::new("/x/y"), Path::new("/z")), None);
|
||||
}
|
||||
|
||||
/// A theme package `id` signed by `publisher` with `key`, packed into `out`.
|
||||
fn signed_theme(tmp: &Path, id: &str, publisher: &str, key: &str, out: &Path) {
|
||||
signed_theme_version(tmp, id, "0.1.0", "^1.0", publisher, key, out);
|
||||
}
|
||||
|
||||
fn signed_theme_version(tmp: &Path, id: &str, version: &str, engine: &str, publisher: &str, key: &str, out: &Path) {
|
||||
let src = tmp.join("src").join(format!("{id}-{version}"));
|
||||
let vars = templates::Vars { id, name: id, sdk: None };
|
||||
for (rel, content) in templates::render(templates::Template::Theme, &vars) {
|
||||
let path = src.join(rel);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
let content = content
|
||||
.replace("version = \"0.1.0\"", &format!("version = \"{version}\""))
|
||||
.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{engine}\""));
|
||||
std::fs::write(path, content).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(out).unwrap();
|
||||
let args = [src.to_str().unwrap(), "--key", key, "--publisher", publisher, "--out"]
|
||||
.into_iter()
|
||||
.map(String::from)
|
||||
.chain([out.join(format!("{id}-{version}.oiplugin")).to_string_lossy().into_owned()])
|
||||
.collect::<Vec<_>>();
|
||||
cmd_pack(&args).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_release_keeps_the_versions_older_ides_need() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
let key = t.join("acme.key");
|
||||
std::fs::write(&key, PublisherKey::from_seed([5; 32]).seed_base64()).unwrap();
|
||||
let key = key.to_string_lossy().into_owned();
|
||||
let index_file = t.join("index.json");
|
||||
let release = |n: usize, packages: &[(&str, &str, &str)]| {
|
||||
let dir = t.join(format!("release-{n}"));
|
||||
for (id, version, engine) in packages {
|
||||
signed_theme_version(t, id, version, engine, "acme", &key, &dir);
|
||||
}
|
||||
let base = format!("https://example.com/download/v{n}");
|
||||
let args = [dir.to_str().unwrap(), "--publisher", "acme", "--key", &key, "--download-base", &base];
|
||||
cmd_index(&strings(&[&args[..], &["--out", index_file.to_str().unwrap()]].concat())).unwrap();
|
||||
let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap();
|
||||
index.plugins.iter().map(|e| format!("{} {} {}", e.id, e.version, e.download)).collect::<Vec<_>>()
|
||||
};
|
||||
release(1, &[("acme.a", "1.0.0", "^1.0"), ("acme.b", "1.0.0", "^1.0")]);
|
||||
// Same requirement: the newer version replaces it; acme.b is gone from the release.
|
||||
assert_eq!(
|
||||
release(2, &[("acme.a", "1.1.0", "^1.0")]),
|
||||
["acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin"]
|
||||
);
|
||||
// Another API requirement: the last version for the earlier one stays, where it was published.
|
||||
assert_eq!(
|
||||
release(3, &[("acme.a", "2.0.0", ">=1.0, <2")]),
|
||||
[
|
||||
"acme.a 1.1.0 https://example.com/download/v2/acme.a-1.1.0.oiplugin",
|
||||
"acme.a 2.0.0 https://example.com/download/v3/acme.a-2.0.0.oiplugin",
|
||||
]
|
||||
);
|
||||
let index = Index::parse(&std::fs::read_to_string(&index_file).unwrap()).unwrap();
|
||||
assert_eq!(index.offered()[0].0.version, "2.0.0", "the newest this IDE can load");
|
||||
}
|
||||
|
||||
fn strings(args: &[&str]) -> Vec<String> {
|
||||
args.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_and_community_indexes_merge() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let t = tmp.path();
|
||||
let mut keys = Vec::new();
|
||||
for (name, seed) in [("openidea", 1), ("alice", 2), ("carol", 3)] {
|
||||
let file = t.join(format!("{name}.key"));
|
||||
let key = PublisherKey::from_seed([seed; 32]);
|
||||
std::fs::write(&file, key.seed_base64()).unwrap();
|
||||
keys.push((file.to_string_lossy().into_owned(), key.public_base64()));
|
||||
}
|
||||
let (official, community) = (t.join("official"), t.join("community"));
|
||||
signed_theme(t, "openidea.night", "openidea", &keys[0].0, &official);
|
||||
signed_theme(t, "alice.dawn", "alice", &keys[1].0, &community);
|
||||
let official_index = official.join("index.json");
|
||||
let community_index = community.join("index.json");
|
||||
let release = strings(&[official.to_str().unwrap(), "--publisher", "openidea", "--key", &keys[0].0]);
|
||||
cmd_index(&release).unwrap();
|
||||
|
||||
// The community index lists its publishers; `index` without a key checks packages against them.
|
||||
let skeleton = Index {
|
||||
format: package::INDEX_FORMAT,
|
||||
publishers: vec![package::Publisher {
|
||||
id: "alice".into(),
|
||||
name: "Alice".into(),
|
||||
keys: vec![KeyEntry::new(keys[1].1.clone())],
|
||||
}],
|
||||
..Index::default()
|
||||
};
|
||||
write_index(&skeleton, &community_index).unwrap();
|
||||
cmd_index(&strings(&[community.to_str().unwrap()])).unwrap();
|
||||
|
||||
cmd_merge(&strings(&[
|
||||
official_index.to_str().unwrap(),
|
||||
community_index.to_str().unwrap(),
|
||||
"--keep",
|
||||
"openidea",
|
||||
]))
|
||||
.unwrap();
|
||||
// A release rebuilds the official plugins and keeps the community's.
|
||||
cmd_index(&release).unwrap();
|
||||
let index = Index::parse(&std::fs::read_to_string(&official_index).unwrap()).unwrap();
|
||||
let listed: Vec<_> = index.plugins.iter().map(|e| (e.id.as_str(), e.publisher.as_str())).collect();
|
||||
assert_eq!(listed, [("alice.dawn", "alice"), ("openidea.night", "openidea")]);
|
||||
assert_eq!(index.plugins[1].categories, ["themes"], "implied by the theme");
|
||||
assert_eq!(index.plugins[1].download, "openidea.night-0.1.0.oiplugin");
|
||||
assert!(Path::new(&index.plugins[0].download).is_absolute(), "{}", index.plugins[0].download);
|
||||
|
||||
// A package by a publisher the index doesn't know is refused.
|
||||
signed_theme(t, "carol.noon", "carol", &keys[2].0, &community);
|
||||
let err = cmd_index(&strings(&[community.to_str().unwrap()])).unwrap_err();
|
||||
assert!(err.contains("unknown publisher"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_template_is_a_valid_package() {
|
||||
for t in templates::Template::ALL {
|
||||
|
||||
@@ -82,6 +82,9 @@ name = "__NAME__"
|
||||
version = "0.1.0"
|
||||
description = "Adds two sample commands."
|
||||
authors = []
|
||||
# Marketplace categories (up to 3): languages, themes, icon-themes, linters,
|
||||
# vcs, ai, frameworks, tools, other.
|
||||
categories = ["tools"]
|
||||
engines.openidea = "^1.0"
|
||||
# Built by `openidea-plugin build`. The code is loaded the first time one of
|
||||
# its commands runs; the commands below show in Find Action before that.
|
||||
|
||||
@@ -30,6 +30,17 @@ pub fn set_command(host: &Host, window: &str, id: String, command: Option<String
|
||||
host.project(window)?.agents.set_builtin_command(&id, command)
|
||||
}
|
||||
|
||||
/// Environment variables built-in agent `id` starts with; empty clears them.
|
||||
pub fn set_env(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
env: std::collections::BTreeMap<String, String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_builtin_env(&id, env)
|
||||
}
|
||||
|
||||
/// Where ACP Registry agents are saved (`<config>/agents`, like the user's own) and installed.
|
||||
pub(crate) fn registry_paths(host: &Host) -> RegistryPaths {
|
||||
RegistryPaths::new(&host.config_dir, &host.local_data_dir)
|
||||
@@ -195,6 +206,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_set_command", |c| async move { set_command(&c.host, &c.window, c.arg("id")?, c.arg("command")?) });
|
||||
d.add("agent_set_env", |c| async move { set_env(&c.host, &c.window, c.arg("id")?, c.arg("env")?) });
|
||||
d.add("agent_registry_list", |c| async move { registry_list(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_registry_install", |c| async move {
|
||||
registry_install(&c.host, &c.window, c.arg("id")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
|
||||
@@ -116,6 +116,11 @@ pub async fn build_sync(
|
||||
tracing::warn!("Maven profiles not saved: {e}");
|
||||
false
|
||||
});
|
||||
if profiles_changed {
|
||||
// The modules of the chosen profiles are read from the POMs with them from now on (the
|
||||
// project tree's module folders too), whether or not Maven itself can run.
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() });
|
||||
}
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
@@ -217,6 +222,52 @@ fn declaring_pom(
|
||||
)))
|
||||
}
|
||||
|
||||
/// The `.toml` of a version catalog the build imports as `group:artifact:version`, once a
|
||||
/// build has downloaded it (`ide_sdk::libraries::published_version_catalog`).
|
||||
pub async fn gradle_published_catalog(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
coordinates: String,
|
||||
) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::libraries::published_version_catalog(&project.root, &project.settings.effective(), &coordinates)
|
||||
.map(Into::into)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The resolved dependency tree of the Gradle project `gradle_path` (`:`, `:app`) for
|
||||
/// `configuration`, from Gradle's `dependencies` report; cancellable with `cancel_request(request)`.
|
||||
pub async fn gradle_dependencies(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
gradle_path: String,
|
||||
configuration: String,
|
||||
offline: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<ide_build::GradleDependency>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let gradle = load(&root, &cache, &settings).gradle;
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
env: gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
gradle_command: gradle.as_ref().map(|g| g.command.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
ide_build::gradle_dependencies(&root, &options, &gradle_path, &configuration, &token)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
|
||||
pub async fn maven_dependency_declaration(
|
||||
host: &Host,
|
||||
@@ -296,7 +347,7 @@ pub async fn build_run(
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let model = with_profiles(load(member, &member_cache, &settings), &member_cache);
|
||||
let plan = compile::plan(member, &model, kind, file.as_deref())?;
|
||||
let plan = compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?;
|
||||
match &mut combined {
|
||||
Some(combined) => combined.steps.extend(plan.steps),
|
||||
None => combined = Some(plan),
|
||||
@@ -306,7 +357,13 @@ pub async fn build_run(
|
||||
plan.title = workspace.name;
|
||||
plan
|
||||
} else {
|
||||
compile::plan(&root, &with_profiles(load(&root, &cache, &settings), &cache), kind, file.as_deref())?
|
||||
compile::plan(
|
||||
&root,
|
||||
&with_profiles(load(&root, &cache, &settings), &cache),
|
||||
kind,
|
||||
file.as_deref(),
|
||||
settings.cmake.profiles.as_deref(),
|
||||
)?
|
||||
};
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
@@ -337,6 +394,20 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("gradle_published_catalog", |c| async move {
|
||||
gradle_published_catalog(&c.host, &c.window, c.arg("coordinates")?).await
|
||||
});
|
||||
d.add("gradle_dependencies", |c| async move {
|
||||
gradle_dependencies(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("gradlePath")?,
|
||||
c.arg("configuration")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("maven_dependency_declaration", |c| async move {
|
||||
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
|
||||
});
|
||||
|
||||
@@ -216,8 +216,7 @@ pub async fn connect(host: &Host, window: &str, id: &str) -> IdeResult<()> {
|
||||
/// manual mode rolls back what was not committed).
|
||||
pub async fn set_manual_commit(host: &Host, window: &str, id: &str, manual: bool) -> IdeResult<()> {
|
||||
let db = host.project(window)?.db.clone();
|
||||
db.set_manual_commit(id, manual).await;
|
||||
Ok(())
|
||||
db.set_manual_commit(id, manual).await
|
||||
}
|
||||
|
||||
/// Commits or rolls back the open transaction of a manual-commit data source.
|
||||
|
||||
@@ -188,6 +188,19 @@ pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) ->
|
||||
host.debugger.stop(session).await
|
||||
}
|
||||
|
||||
/// The adapter's completions for an expression being typed in the frame `frame_id`.
|
||||
pub async fn debug_completions(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
text: String,
|
||||
line: u32,
|
||||
column: u32,
|
||||
) -> IdeResult<Vec<ide_dap::DebugCompletion>> {
|
||||
host.debugger.completions(session, frame_id, text, line, column).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
pub async fn debug_evaluate_in(
|
||||
host: &Host,
|
||||
@@ -376,6 +389,18 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
.await
|
||||
});
|
||||
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_completions", |c| async move {
|
||||
debug_completions(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("text")?,
|
||||
c.arg("line")?,
|
||||
c.arg("column")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_evaluate_in", |c| async move {
|
||||
debug_evaluate_in(
|
||||
&c.host,
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Docker run configurations: the environment their `docker` command line runs with.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::IdeResult;
|
||||
use ide_docker::DockerEndpoint;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// The endpoint's variables plus a `PATH` that finds this host's `docker`. A remote project
|
||||
/// gets them from the server, since its run configurations start on the remote host.
|
||||
pub async fn run_env(host: &Host, endpoint: DockerEndpoint) -> IdeResult<BTreeMap<String, String>> {
|
||||
endpoint.validate()?;
|
||||
host.blocking(move || Ok(ide_docker::run_env(&endpoint))).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("docker_run_env", |c| async move { run_env(&c.host, c.arg("endpoint")?).await });
|
||||
}
|
||||
@@ -412,6 +412,56 @@ pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos)
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
/// The class at `pos` and the members a class-hierarchy refactoring can move.
|
||||
pub async fn lsp_hierarchy_members(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
) -> IdeResult<HierarchyMembers> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.hierarchy_members(doc, pos, kind, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Checks a class-hierarchy refactoring and computes its edit without applying it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn lsp_hierarchy_refactor(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
target: Option<String>,
|
||||
choices: Vec<HierarchyChoice>,
|
||||
) -> IdeResult<HierarchyRefactored> {
|
||||
let project = host.project(window)?;
|
||||
let documents = project.documents.clone();
|
||||
let version = blocking(move || documents.version(doc)).await?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = project.lsp.hierarchy_refactor(doc, pos, kind, target, choices, cancel).await;
|
||||
host.end_request(request);
|
||||
let mut refactored = result?;
|
||||
blocking(move || {
|
||||
if project.documents.version(doc)? != version {
|
||||
return Err(ide_core::IdeError::new(
|
||||
ide_core::error::ErrorKind::OutOfSync,
|
||||
"document changed while computing the refactoring; request it again",
|
||||
));
|
||||
}
|
||||
if let Some(edit) = &mut refactored.edit {
|
||||
project.documents.prepare_workspace_edit(edit)?;
|
||||
}
|
||||
Ok(refactored)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn lsp_rename(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -676,6 +726,22 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_hierarchy_members", |c| async move {
|
||||
lsp_hierarchy_members(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await
|
||||
});
|
||||
d.add("lsp_hierarchy_refactor", |c| async move {
|
||||
lsp_hierarchy_refactor(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("pos")?,
|
||||
c.arg("kind")?,
|
||||
c.arg("target")?,
|
||||
c.arg("choices")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ pub mod debug;
|
||||
pub mod dependencies;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod docker;
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
@@ -87,6 +88,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
run::register(d);
|
||||
env::register(d);
|
||||
deploy::register(d);
|
||||
docker::register(d);
|
||||
db::register(d);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
}
|
||||
|
||||
/// A project's detected configurations (minus removed ones and unlinked Maven projects), then
|
||||
/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved:
|
||||
/// its saved ones; toolchains from Languages & Frameworks applied. Build-tool choices stay as saved:
|
||||
/// derived defaults must not be written back as explicit environment.
|
||||
fn detected_and_saved(
|
||||
root: &std::path::Path,
|
||||
@@ -56,7 +56,7 @@ fn detected_and_saved(
|
||||
.filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id))
|
||||
.collect();
|
||||
all.extend(saved.iter().cloned());
|
||||
for config in all.iter_mut().filter(|c| c.kind != "gradle") {
|
||||
for config in all.iter_mut().filter(|c| !matches!(c.kind.as_str(), "gradle" | "maven")) {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings);
|
||||
}
|
||||
all
|
||||
@@ -75,13 +75,65 @@ pub fn save_configs(
|
||||
let configs: Vec<RunConfig> = configs
|
||||
.iter()
|
||||
.map(|c| match c.kind.as_str() {
|
||||
"gradle" => c.clone(),
|
||||
"gradle" | "maven" => c.clone(),
|
||||
_ => ide_sdk::apply::without_derived(c, &project.root, &settings),
|
||||
})
|
||||
.collect();
|
||||
ide_run::save_user_configs_with_removals(&project.root, &configs, removed_detected_ids)
|
||||
}
|
||||
|
||||
/// The project `config` runs in (a workspace member, by its working directory) and its settings.
|
||||
fn settings_for(
|
||||
host: &Host,
|
||||
project: &crate::project::Project,
|
||||
config: &RunConfig,
|
||||
) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> {
|
||||
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
|
||||
let cwd = config.cwd.as_deref()?;
|
||||
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
|
||||
});
|
||||
Ok(match member {
|
||||
Some(root) => {
|
||||
let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective();
|
||||
(root, settings)
|
||||
}
|
||||
None => (project.root.clone(), project.settings.effective()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Temporary configurations as they are kept between sessions: without what settings derived
|
||||
/// (toolchain commands, Maven options, `JAVA_HOME`…), so they follow later settings changes.
|
||||
pub fn without_derived(host: &Host, window: &str, configs: Vec<RunConfig>) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
configs
|
||||
.into_iter()
|
||||
.map(|c| {
|
||||
if c.kind == "gradle" {
|
||||
return Ok(c);
|
||||
}
|
||||
let (root, settings) = settings_for(host, &project, &c)?;
|
||||
Ok(ide_sdk::apply::without_derived(&c, &root, &settings))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Kept temporary configurations with today's settings applied, as [`configs`] lists the others.
|
||||
pub fn apply_settings(host: &Host, window: &str, configs: Vec<RunConfig>) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
configs
|
||||
.into_iter()
|
||||
.map(|mut c| {
|
||||
if c.kind != "gradle" {
|
||||
let (root, settings) = settings_for(host, &project, &c)?;
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut c), &root, &settings);
|
||||
}
|
||||
Ok(c)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
@@ -100,18 +152,13 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| {
|
||||
let cwd = config.cwd.as_deref()?;
|
||||
workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count())
|
||||
});
|
||||
let root = member.as_ref().unwrap_or(&project.root);
|
||||
let settings = match &member {
|
||||
Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(),
|
||||
None => project.settings.effective(),
|
||||
};
|
||||
let (root, settings) = settings_for(host, &project, &config)?;
|
||||
let root = &root;
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
let mut config = config;
|
||||
if matches!(config.kind.as_str(), "gradle" | "tomcat") {
|
||||
if config.kind == "maven" {
|
||||
ide_sdk::apply::prepare_maven_run(&mut config, root, &settings);
|
||||
} else if matches!(config.kind.as_str(), "gradle" | "tomcat") {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
@@ -147,7 +194,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
python_paths: if *root != project.root { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
project.require_trust()?;
|
||||
let mut sink = project.sink.clone();
|
||||
|
||||
@@ -241,6 +241,35 @@ pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mod
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Updates a running Tomcat artifact deployment from the module's compiled output and web
|
||||
/// resources (`ide_run::tomcat::sync_outputs`). `false`: rebuild the WAR instead.
|
||||
pub async fn run_tomcat_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
config: RunConfig,
|
||||
module: std::path::PathBuf,
|
||||
class_paths: Vec<String>,
|
||||
resources_only: bool,
|
||||
) -> IdeResult<bool> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
}
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(false);
|
||||
}
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let module = dunce::canonicalize(project.root.join(module))?;
|
||||
let class_paths: Vec<_> =
|
||||
class_paths.iter().filter_map(|p| dunce::canonicalize(project.root.join(p)).ok()).collect();
|
||||
let base = ide_run::tomcat::base_dir(&project.cache_dir, &config.id);
|
||||
ide_run::tomcat::sync_outputs(&base, &module, &class_paths, resources_only)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_dotnet_resolve", |c| async move {
|
||||
@@ -255,6 +284,17 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_tomcat_home", |c| async move { run_tomcat_home(&c.host, &c.window, c.arg("home")?).await });
|
||||
d.add("run_tomcat_artifacts", |c| async move { run_tomcat_artifacts(&c.host, &c.window).await });
|
||||
d.add("run_tomcat_build", |c| async move { run_tomcat_build(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("run_tomcat_sync", |c| async move {
|
||||
run_tomcat_sync(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("config")?,
|
||||
c.arg("module")?,
|
||||
c.arg("classPaths")?,
|
||||
c.arg("resourcesOnly")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("run_tomcat_update", |c| async move {
|
||||
run_tomcat_update(&c.host, &c.window, c.arg("config")?, c.arg("mode")?).await
|
||||
});
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use super::icon_patterns::validate_pattern;
|
||||
use ide_extension::validate_item_id;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const MAX_GROUPS: usize = 64;
|
||||
const MAX_CHOICES: usize = 16;
|
||||
const MAX_CHOICE_OPTIONS: usize = 16;
|
||||
const MAX_NAME_LEN: usize = 255;
|
||||
pub(super) const DARK_SUFFIX: &str = "_dark";
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum IconDefinition {
|
||||
Path(PathBuf),
|
||||
Variants(IconVariants),
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct IconVariants {
|
||||
light: PathBuf,
|
||||
#[serde(default)]
|
||||
dark: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl IconDefinition {
|
||||
pub(super) fn light(&self) -> &PathBuf {
|
||||
match self {
|
||||
IconDefinition::Path(p) => p,
|
||||
IconDefinition::Variants(v) => &v.light,
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn dark(&self) -> Option<&PathBuf> {
|
||||
match self {
|
||||
IconDefinition::Path(_) => None,
|
||||
IconDefinition::Variants(v) => v.dark.as_ref(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) enum Target {
|
||||
#[default]
|
||||
File,
|
||||
Folder,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct Pattern {
|
||||
pattern: String,
|
||||
icon: String,
|
||||
#[serde(default)]
|
||||
target: Target,
|
||||
#[serde(default)]
|
||||
path: bool,
|
||||
#[serde(default)]
|
||||
ignore_case: bool,
|
||||
#[serde(default)]
|
||||
group: Option<String>,
|
||||
#[serde(default)]
|
||||
open_icon: Option<String>,
|
||||
}
|
||||
|
||||
fn enabled_by_default() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Group {
|
||||
pub(super) id: String,
|
||||
name: String,
|
||||
#[serde(default = "enabled_by_default")]
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct ChoiceOption {
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
#[serde(default)]
|
||||
overrides: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct Choice {
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) default: String,
|
||||
pub(super) options: Vec<ChoiceOption>,
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) struct Extensions {
|
||||
#[serde(default)]
|
||||
pub(super) patterns: Vec<Pattern>,
|
||||
#[serde(default)]
|
||||
pub(super) overrides: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub(super) groups: Vec<Group>,
|
||||
#[serde(default)]
|
||||
pub(super) choices: Vec<Choice>,
|
||||
#[serde(default)]
|
||||
pub(super) accent: bool,
|
||||
}
|
||||
|
||||
pub(super) fn check_icon_id(id: &str) -> Result<(), String> {
|
||||
validate_item_id(id, "icon id")?;
|
||||
if id.ends_with(DARK_SUFFIX) {
|
||||
return Err(format!("icon id {id:?}: the {DARK_SUFFIX:?} suffix is reserved for dark variants"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_named(id: &str, name: &str, what: &str) -> Result<(), String> {
|
||||
validate_item_id(id, what)?;
|
||||
if name.trim().is_empty() || name.len() > MAX_NAME_LEN {
|
||||
return Err(format!("{what} {id:?}: name must have 1 to {MAX_NAME_LEN} characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn check_unique<'a>(ids: impl Iterator<Item = &'a String>, what: &str) -> Result<(), String> {
|
||||
let mut seen = BTreeSet::new();
|
||||
match ids.into_iter().find(|id| !seen.insert(*id)) {
|
||||
Some(id) => Err(format!("duplicate {what} {id:?}")),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
impl Extensions {
|
||||
pub(super) fn mappings(&self) -> usize {
|
||||
let chosen: usize = self.choices.iter().flat_map(|c| &c.options).map(|o| o.overrides.len()).sum();
|
||||
self.patterns.len() + self.overrides.len() + chosen
|
||||
}
|
||||
|
||||
pub(super) fn validate(&self, check_ref: &dyn Fn(&str, &str) -> Result<(), String>) -> Result<(), String> {
|
||||
self.validate_groups()?;
|
||||
for p in &self.patterns {
|
||||
validate_pattern(&p.pattern)?;
|
||||
check_ref(&p.icon, "patterns")?;
|
||||
if let Some(group) = &p.group
|
||||
&& !self.groups.iter().any(|g| &g.id == group)
|
||||
{
|
||||
return Err(format!("pattern {:?}: unknown group {group:?}", p.pattern));
|
||||
}
|
||||
if let Some(open) = &p.open_icon {
|
||||
if p.target != Target::Folder {
|
||||
return Err(format!("pattern {:?}: openIcon is only for folder patterns", p.pattern));
|
||||
}
|
||||
check_ref(open, "patterns")?;
|
||||
}
|
||||
}
|
||||
check_overrides(&self.overrides, check_ref, "overrides")?;
|
||||
for choice in &self.choices {
|
||||
for option in &choice.options {
|
||||
check_overrides(&option.overrides, check_ref, "choices")?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_groups(&self) -> Result<(), String> {
|
||||
if self.groups.len() > MAX_GROUPS || self.choices.len() > MAX_CHOICES {
|
||||
return Err(format!("too many groups or choices (max {MAX_GROUPS} and {MAX_CHOICES})"));
|
||||
}
|
||||
for g in &self.groups {
|
||||
check_named(&g.id, &g.name, "group")?;
|
||||
}
|
||||
check_unique(self.groups.iter().map(|g| &g.id), "group")?;
|
||||
check_unique(self.choices.iter().map(|c| &c.id), "choice")?;
|
||||
for choice in &self.choices {
|
||||
check_named(&choice.id, &choice.name, "choice")?;
|
||||
if choice.options.is_empty() || choice.options.len() > MAX_CHOICE_OPTIONS {
|
||||
return Err(format!("choice {:?}: expected 1 to {MAX_CHOICE_OPTIONS} options", choice.id));
|
||||
}
|
||||
for option in &choice.options {
|
||||
check_named(&option.id, &option.name, "choice option")?;
|
||||
}
|
||||
check_unique(choice.options.iter().map(|o| &o.id), "choice option")?;
|
||||
if !choice.options.iter().any(|o| o.id == choice.default) {
|
||||
return Err(format!("choice {:?}: default {:?} is not one of its options", choice.id, choice.default));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn check_overrides(
|
||||
overrides: &BTreeMap<String, String>,
|
||||
check_ref: &dyn Fn(&str, &str) -> Result<(), String>,
|
||||
what: &str,
|
||||
) -> Result<(), String> {
|
||||
for (name, icon) in overrides {
|
||||
validate_item_id(name, "overridden icon name")?;
|
||||
check_ref(icon, what)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
use regex_syntax::hir::{Hir, HirKind};
|
||||
|
||||
pub(crate) const MAX_PATTERN_LEN: usize = 512;
|
||||
const NAME_LEN: u64 = 256;
|
||||
const MAX_COST: u64 = 4 * NAME_LEN * NAME_LEN;
|
||||
|
||||
const UNSUPPORTED: &[(&str, &str)] = &[
|
||||
("\\A", "\\A"),
|
||||
("\\z", "\\z"),
|
||||
("\\Z", "\\Z"),
|
||||
("\\p", "\\p"),
|
||||
("\\P", "\\P"),
|
||||
("[[:", "a POSIX class"),
|
||||
("&&", "class intersection"),
|
||||
("~~", "class symmetric difference"),
|
||||
];
|
||||
|
||||
pub(crate) fn validate_pattern(pattern: &str) -> Result<(), String> {
|
||||
if pattern.len() > MAX_PATTERN_LEN {
|
||||
return Err(format!("pattern {pattern:?} is too long (max {MAX_PATTERN_LEN})"));
|
||||
}
|
||||
if let Some(construct) = unsupported_construct(pattern) {
|
||||
return Err(format!("pattern {pattern:?}: {construct} is not supported"));
|
||||
}
|
||||
let hir = regex_syntax::Parser::new().parse(pattern).map_err(|e| format!("invalid pattern {pattern:?}: {e}"))?;
|
||||
match cost(&hir) {
|
||||
Err(why) => Err(format!("pattern {pattern:?}: {why} is not supported")),
|
||||
Ok(c) if c > MAX_COST => Err(format!("pattern {pattern:?} is too complex: too many ways to match one name")),
|
||||
Ok(_) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn unsupported_construct(pattern: &str) -> Option<&'static str> {
|
||||
if pattern.match_indices("(?").any(|(at, _)| !pattern[at + 2..].starts_with(':')) {
|
||||
return Some("an inline flag, lookaround or named group");
|
||||
}
|
||||
UNSUPPORTED.iter().find(|(needle, _)| pattern.contains(needle)).map(|(_, what)| *what)
|
||||
}
|
||||
|
||||
fn cost(hir: &Hir) -> Result<u64, &'static str> {
|
||||
match hir.kind() {
|
||||
HirKind::Empty | HirKind::Literal(_) | HirKind::Class(_) | HirKind::Look(_) => Ok(1),
|
||||
HirKind::Capture(c) => cost(&c.sub),
|
||||
HirKind::Concat(items) => items.iter().try_fold(1u64, |acc, h| Ok(acc.saturating_mul(cost(h)?))),
|
||||
HirKind::Alternation(items) => items.iter().try_fold(0u64, |acc, h| Ok(acc.saturating_add(cost(h)?))),
|
||||
HirKind::Repetition(r) => match r.max {
|
||||
None if matches!(r.sub.kind(), HirKind::Literal(_) | HirKind::Class(_)) => Ok(NAME_LEN),
|
||||
None => Err("a repeated group (`(…)*`, `(…)+`)"),
|
||||
Some(max) => Ok(cost(&r.sub)?.saturating_add(1).saturating_pow(max)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn err(pattern: &str) -> String {
|
||||
validate_pattern(pattern).unwrap_err()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_typical_file_name_patterns() {
|
||||
for pattern in [
|
||||
r"^(architecture|ARCHITECTURE)(\.(md|rst|txt))?$",
|
||||
r"(main|workflow|ci|release|build|config)\.ya?ml$",
|
||||
r"^[a-z]+\.d\.ts$",
|
||||
r"^\.?eslintrc(\.(js|cjs|json|ya?ml))?$",
|
||||
r"^.*\.config\.[cm]?[jt]s$",
|
||||
r"^.*-(test|spec)\..*$",
|
||||
r"^[0-9]{4}-[0-9]{2}\.log$",
|
||||
] {
|
||||
assert!(validate_pattern(pattern).is_ok(), "{pattern}: {:?}", validate_pattern(pattern));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_patterns_that_backtrack_catastrophically() {
|
||||
assert!(err(r"(a+)+$").contains("repeated group"));
|
||||
assert!(err(r"(?:a*)*").contains("repeated group"));
|
||||
assert!(err(r"^(\w|\w\w)*!$").contains("repeated group"));
|
||||
assert!(err(r"^(a|aa)+$").contains("repeated group"));
|
||||
assert!(err(r"^.*.*.*x$").contains("too complex"));
|
||||
assert!(err(r"^(a?){30}a{30}$").contains("too complex"));
|
||||
assert!(err(&format!("^{}b$", "(a|aa)".repeat(20))).contains("too complex"));
|
||||
assert!(err(r"x{0,40}").contains("too complex"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_constructs_javascript_reads_differently() {
|
||||
assert!(err(r"(?i)readme").contains("not supported"));
|
||||
assert!(err(r"(?P<n>a)").contains("not supported"));
|
||||
assert!(err(r"\Areadme").contains("not supported"));
|
||||
assert!(err(r"readme\z").contains("not supported"));
|
||||
assert!(err(r"\p{L}").contains("not supported"));
|
||||
assert!(err(r"[[:alpha:]]").contains("not supported"));
|
||||
assert!(err(r"[a-z&&[^x]]").contains("not supported"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_and_oversized_patterns() {
|
||||
assert!(err("(").contains("invalid pattern"));
|
||||
assert!(err(&"a".repeat(MAX_PATTERN_LEN + 1)).contains("too long"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use super::icon_extensions::Extensions;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_SECTIONS: usize = 16;
|
||||
const MAX_ITEMS: usize = 32;
|
||||
const MAX_TEXT_LEN: usize = 255;
|
||||
const MAX_HINT_LEN: usize = 512;
|
||||
const MAX_COLUMNS: u8 = 4;
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(super) enum SettingOption {
|
||||
HideFiles,
|
||||
HideFolders,
|
||||
HideUiIcons,
|
||||
HidePsiIcons,
|
||||
HollowFolders,
|
||||
Groups,
|
||||
Choice,
|
||||
Accent,
|
||||
Monochrome,
|
||||
Saturation,
|
||||
Opacity,
|
||||
IconSize,
|
||||
RowHeight,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(untagged)]
|
||||
pub(super) enum SettingDefault {
|
||||
Number(u8),
|
||||
Color(String),
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
pub(super) struct SettingItem {
|
||||
option: SettingOption,
|
||||
#[serde(default)]
|
||||
label: Option<String>,
|
||||
#[serde(default)]
|
||||
hint: Option<String>,
|
||||
#[serde(default)]
|
||||
invert: bool,
|
||||
#[serde(default)]
|
||||
choice: Option<String>,
|
||||
#[serde(default)]
|
||||
columns: Option<u8>,
|
||||
#[serde(default)]
|
||||
default: Option<SettingDefault>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub(super) struct SettingsSection {
|
||||
title: String,
|
||||
items: Vec<SettingItem>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ChoiceSummary<'a> {
|
||||
id: &'a str,
|
||||
name: &'a str,
|
||||
default: &'a str,
|
||||
options: Vec<OptionSummary<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct OptionSummary<'a> {
|
||||
id: &'a str,
|
||||
name: &'a str,
|
||||
}
|
||||
|
||||
fn check_text(text: &str, max: usize, what: &str) -> Result<(), String> {
|
||||
if text.trim().is_empty() || text.len() > max {
|
||||
return Err(format!("settings: {what} must have 1 to {max} characters"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn range(option: SettingOption) -> Option<(u8, u8)> {
|
||||
match option {
|
||||
SettingOption::Saturation => Some((0, 20)),
|
||||
SettingOption::Opacity => Some((10, 100)),
|
||||
SettingOption::IconSize => Some((12, 32)),
|
||||
SettingOption::RowHeight => Some((16, 48)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_color(text: &str) -> bool {
|
||||
text.len() == 7 && text.starts_with('#') && text[1..].chars().all(|c| c.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
fn is_flag(option: SettingOption) -> bool {
|
||||
matches!(
|
||||
option,
|
||||
SettingOption::HideFiles
|
||||
| SettingOption::HideFolders
|
||||
| SettingOption::HideUiIcons
|
||||
| SettingOption::HidePsiIcons
|
||||
| SettingOption::HollowFolders
|
||||
)
|
||||
}
|
||||
|
||||
impl SettingItem {
|
||||
fn validate(&self, ext: &Extensions) -> Result<(), String> {
|
||||
let named = !matches!(self.option, SettingOption::Groups | SettingOption::Choice);
|
||||
match &self.label {
|
||||
Some(label) => check_text(label, MAX_TEXT_LEN, "label")?,
|
||||
None if named => return Err("settings: label is required".into()),
|
||||
None => {}
|
||||
}
|
||||
if let Some(hint) = &self.hint {
|
||||
check_text(hint, MAX_HINT_LEN, "hint")?;
|
||||
}
|
||||
if self.invert && !is_flag(self.option) {
|
||||
return Err("settings: invert is only for on/off options".into());
|
||||
}
|
||||
if self.columns.is_some_and(|c| c == 0 || c > MAX_COLUMNS)
|
||||
|| (self.columns.is_some() && self.option != SettingOption::Groups)
|
||||
{
|
||||
return Err(format!("settings: columns are 1 to {MAX_COLUMNS}, for groups only"));
|
||||
}
|
||||
match (self.option, &self.choice) {
|
||||
(SettingOption::Choice, Some(id)) if !ext.choices.iter().any(|c| &c.id == id) => {
|
||||
return Err(format!("settings: unknown choice {id:?}"));
|
||||
}
|
||||
(SettingOption::Choice, None) => return Err("settings: choice items name their choice".into()),
|
||||
(SettingOption::Choice, Some(_)) | (_, None) => {}
|
||||
(_, Some(_)) => return Err("settings: only choice items name a choice".into()),
|
||||
}
|
||||
if self.option == SettingOption::Accent && !ext.accent {
|
||||
return Err("settings: the accent option needs \"accent\": true".into());
|
||||
}
|
||||
self.validate_default()
|
||||
}
|
||||
|
||||
fn validate_default(&self) -> Result<(), String> {
|
||||
let colored = matches!(self.option, SettingOption::Accent | SettingOption::Monochrome);
|
||||
match (range(self.option), &self.default) {
|
||||
(Some((min, max)), Some(SettingDefault::Number(n))) if (min..=max).contains(n) => Ok(()),
|
||||
(Some((min, max)), _) => Err(format!("settings: a default from {min} to {max} is required")),
|
||||
(None, Some(SettingDefault::Color(c))) if colored && is_color(c) => Ok(()),
|
||||
(None, _) if colored => Err("settings: a #rrggbb default is required".into()),
|
||||
(None, None) => Ok(()),
|
||||
(None, Some(_)) => Err("settings: this option takes no default".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn validate(sections: &[SettingsSection], ext: &Extensions) -> Result<(), String> {
|
||||
if sections.len() > MAX_SECTIONS {
|
||||
return Err(format!("settings: more than {MAX_SECTIONS} sections"));
|
||||
}
|
||||
for section in sections {
|
||||
check_text(§ion.title, MAX_TEXT_LEN, "title")?;
|
||||
if section.items.len() > MAX_ITEMS {
|
||||
return Err(format!("settings: more than {MAX_ITEMS} items in {:?}", section.title));
|
||||
}
|
||||
for item in §ion.items {
|
||||
item.validate(ext)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn summary(sections: &[SettingsSection], ext: &Extensions) -> serde_json::Value {
|
||||
let choices: Vec<ChoiceSummary> = ext
|
||||
.choices
|
||||
.iter()
|
||||
.map(|c| ChoiceSummary {
|
||||
id: &c.id,
|
||||
name: &c.name,
|
||||
default: &c.default,
|
||||
options: c.options.iter().map(|o| OptionSummary { id: &o.id, name: &o.name }).collect(),
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({
|
||||
"groups": ext.groups,
|
||||
"choices": choices,
|
||||
"accent": ext.accent,
|
||||
"settings": sections,
|
||||
})
|
||||
}
|
||||
@@ -24,6 +24,8 @@
|
||||
//! asset; the client fetches the mapping and the icons only when the theme is
|
||||
//! selected, so installed icon themes cost nothing at start-up.
|
||||
|
||||
use super::icon_extensions::{Choice, DARK_SUFFIX, Extensions, Group, IconDefinition, Pattern, check_icon_id};
|
||||
use super::icon_settings::{self, SettingsSection};
|
||||
use ide_extension::{Package, UiDecl, check_relative, decode, validate_item_id};
|
||||
use serde::Deserialize;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -45,7 +47,7 @@ struct Decl {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ThemeFile {
|
||||
icon_definitions: BTreeMap<String, PathBuf>,
|
||||
icon_definitions: BTreeMap<String, IconDefinition>,
|
||||
#[serde(default)]
|
||||
file: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -53,11 +55,25 @@ struct ThemeFile {
|
||||
#[serde(default)]
|
||||
folder_expanded: Option<String>,
|
||||
#[serde(default)]
|
||||
folder_open: Option<String>,
|
||||
#[serde(default)]
|
||||
file_extensions: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
file_names: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
folder_names: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
patterns: Vec<Pattern>,
|
||||
#[serde(default)]
|
||||
overrides: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
groups: Vec<Group>,
|
||||
#[serde(default)]
|
||||
choices: Vec<Choice>,
|
||||
#[serde(default)]
|
||||
accent: bool,
|
||||
#[serde(default)]
|
||||
settings: Vec<SettingsSection>,
|
||||
}
|
||||
|
||||
/// Asset id of a theme's mapping (JSON).
|
||||
@@ -65,6 +81,10 @@ pub fn map_asset(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/map")
|
||||
}
|
||||
|
||||
pub fn settings_asset(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/settings")
|
||||
}
|
||||
|
||||
/// Asset id prefix of a theme's SVGs (`…/svg/<icon id>`).
|
||||
pub fn svg_prefix(theme: &str) -> String {
|
||||
format!("iconTheme/{theme}/svg/")
|
||||
@@ -87,12 +107,20 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
|
||||
return Err(format!("icon theme {:?} has an empty label", d.id));
|
||||
}
|
||||
let src = pkg.read_text(&d.path)?;
|
||||
let theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
|
||||
let mut theme: ThemeFile = serde_json::from_str(&src).map_err(|e| format!("{}: {e}", d.path.display()))?;
|
||||
let extensions = Extensions {
|
||||
patterns: std::mem::take(&mut theme.patterns),
|
||||
overrides: std::mem::take(&mut theme.overrides),
|
||||
groups: std::mem::take(&mut theme.groups),
|
||||
choices: std::mem::take(&mut theme.choices),
|
||||
accent: theme.accent,
|
||||
};
|
||||
let at = |what: &str| format!("{} ({what})", d.path.display());
|
||||
if theme.icon_definitions.len() > MAX_ICONS {
|
||||
return Err(format!("{}: more than {MAX_ICONS} icons", d.path.display()));
|
||||
}
|
||||
let mappings = theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len();
|
||||
let mappings =
|
||||
theme.file_extensions.len() + theme.file_names.len() + theme.folder_names.len() + extensions.mappings();
|
||||
if mappings > MAX_MAPPINGS {
|
||||
return Err(format!("{}: more than {MAX_MAPPINGS} mappings", d.path.display()));
|
||||
}
|
||||
@@ -100,7 +128,17 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
|
||||
let check_ref = |icon: &str, what: &str| -> Result<(), String> {
|
||||
if defined.contains(icon) { Ok(()) } else { Err(format!("{}: unknown icon {icon:?}", at(what))) }
|
||||
};
|
||||
for (what, icon) in [("file", &theme.file), ("folder", &theme.folder), ("folderExpanded", &theme.folder_expanded)] {
|
||||
for icon in theme.icon_definitions.keys() {
|
||||
check_icon_id(icon)?;
|
||||
}
|
||||
extensions.validate(&check_ref)?;
|
||||
icon_settings::validate(&theme.settings, &extensions).map_err(|e| format!("{}: {e}", d.path.display()))?;
|
||||
for (what, icon) in [
|
||||
("file", &theme.file),
|
||||
("folder", &theme.folder),
|
||||
("folderExpanded", &theme.folder_expanded),
|
||||
("folderOpen", &theme.folder_open),
|
||||
] {
|
||||
if let Some(icon) = icon {
|
||||
check_ref(icon, what)?;
|
||||
}
|
||||
@@ -125,20 +163,31 @@ pub(super) fn icon_theme(pkg: &Package, decl: toml::Value) -> Result<UiDecl, Str
|
||||
|
||||
let mut assets = Vec::with_capacity(theme.icon_definitions.len() + 1);
|
||||
let mut files = vec![d.path.clone()];
|
||||
for (icon, path) in &theme.icon_definitions {
|
||||
validate_item_id(icon, "icon id")?;
|
||||
let clean = read_icon(pkg, path)?;
|
||||
assets.push((format!("{}{icon}", svg_prefix(&d.id)), clean));
|
||||
files.push(path.clone());
|
||||
for (icon, def) in &theme.icon_definitions {
|
||||
let dark = def.dark().map(|path| (path, DARK_SUFFIX));
|
||||
for (path, suffix) in std::iter::once((def.light(), "")).chain(dark) {
|
||||
let clean = read_icon(pkg, path)?;
|
||||
assets.push((format!("{}{icon}{suffix}", svg_prefix(&d.id)), clean));
|
||||
files.push(path.clone());
|
||||
}
|
||||
}
|
||||
let map = serde_json::json!({
|
||||
"file": theme.file,
|
||||
"folder": theme.folder,
|
||||
"folderExpanded": theme.folder_expanded,
|
||||
"folderOpen": theme.folder_open,
|
||||
"fileExtensions": file_extensions,
|
||||
"fileNames": file_names,
|
||||
"folderNames": folder_names,
|
||||
"patterns": extensions.patterns,
|
||||
"overrides": extensions.overrides,
|
||||
"groups": extensions.groups,
|
||||
"choices": extensions.choices,
|
||||
"accent": extensions.accent,
|
||||
});
|
||||
if !theme.settings.is_empty() {
|
||||
assets.push((settings_asset(&d.id), icon_settings::summary(&theme.settings, &extensions).to_string()));
|
||||
}
|
||||
assets.push((map_asset(&d.id), map.to_string()));
|
||||
let json = serde_json::json!({ "id": d.id, "label": d.label, "icons": theme.icon_definitions.len() });
|
||||
let mut out = UiDecl::new(d.id.clone(), d.label, json);
|
||||
@@ -612,4 +661,184 @@ mod tests {
|
||||
assert!(icon_theme(&pkg, decl("evil.json")).unwrap_err().contains("evil.svg"));
|
||||
assert!(icon_theme(&pkg, decl("../x.json")).is_err());
|
||||
}
|
||||
|
||||
const SVG: &str = r#"<svg viewBox="0 0 16 16"><path d="M0 0h16v16z"/></svg>"#;
|
||||
|
||||
fn theme_package(theme: &str) -> (tempfile::TempDir, Package) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join("icons")).unwrap();
|
||||
for icon in ["file", "folder", "folder-src", "folder-src-open", "rust", "rust-dark", "tests", "arrow"] {
|
||||
std::fs::write(dir.join(format!("icons/{icon}.svg")), SVG).unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("theme.json"), theme).unwrap();
|
||||
let manifest = ide_extension::Manifest::parse(
|
||||
"id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"",
|
||||
)
|
||||
.unwrap();
|
||||
let pkg = Package::new(manifest, dir.to_path_buf());
|
||||
(tmp, pkg)
|
||||
}
|
||||
|
||||
fn load(theme: &str) -> Result<UiDecl, String> {
|
||||
let (_tmp, pkg) = theme_package(theme);
|
||||
let mut t = toml::Table::new();
|
||||
t.insert("id".into(), "m".into());
|
||||
t.insert("label".into(), "M".into());
|
||||
t.insert("path".into(), "theme.json".into());
|
||||
icon_theme(&pkg, toml::Value::Table(t))
|
||||
}
|
||||
|
||||
fn with(extra: &str) -> String {
|
||||
format!(
|
||||
r#"{{"iconDefinitions":{{"file":"icons/file.svg","folder":"icons/folder.svg",
|
||||
"folder-src":"icons/folder-src.svg","folder-src-open":"icons/folder-src-open.svg",
|
||||
"rust":{{"light":"icons/rust.svg","dark":"icons/rust-dark.svg"}},
|
||||
"tests":"icons/tests.svg","arrow":"icons/arrow.svg"}},
|
||||
"file":"file","folder":"folder"{extra}}}"#
|
||||
)
|
||||
}
|
||||
|
||||
const EXTENSIONS: &str = r#","patterns":[
|
||||
{"pattern":"^.*\\.rs$","icon":"rust","ignoreCase":true},
|
||||
{"pattern":"/tests?/","icon":"tests","path":true,"group":"tests"},
|
||||
{"pattern":"^src$","icon":"folder-src","target":"folder","openIcon":"folder-src-open"}],
|
||||
"overrides":{"chevron-right":"arrow","symbol-class":"rust"},
|
||||
"groups":[{"id":"tests","name":"Tests","enabled":false}],
|
||||
"choices":[{"id":"arrows","name":"Arrows","default":"plain","options":[
|
||||
{"id":"plain","name":"Default"},{"id":"material","name":"Material","overrides":{"chevron-down":"arrow"}}]}],
|
||||
"accent":true,"folderOpen":"folder-src-open""#;
|
||||
|
||||
fn map_of(d: &UiDecl) -> serde_json::Value {
|
||||
serde_json::from_str(&d.assets.iter().find(|(k, _)| *k == map_asset("m")).unwrap().1).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_extensions_reach_the_mapping() {
|
||||
let d = load(&with(EXTENSIONS)).unwrap();
|
||||
let map = map_of(&d);
|
||||
assert_eq!(
|
||||
map["patterns"],
|
||||
serde_json::json!([
|
||||
{"pattern":"^.*\\.rs$","icon":"rust","target":"file","path":false,"ignoreCase":true,"group":null,"openIcon":null},
|
||||
{"pattern":"/tests?/","icon":"tests","target":"file","path":true,"ignoreCase":false,"group":"tests","openIcon":null},
|
||||
{"pattern":"^src$","icon":"folder-src","target":"folder","path":false,"ignoreCase":false,"group":null,"openIcon":"folder-src-open"}
|
||||
])
|
||||
);
|
||||
assert_eq!(map["overrides"], serde_json::json!({"chevron-right":"arrow","symbol-class":"rust"}));
|
||||
assert_eq!(map["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}]));
|
||||
assert_eq!(map["choices"][0]["default"], "plain");
|
||||
assert_eq!(map["choices"][0]["options"][1]["overrides"], serde_json::json!({"chevron-down":"arrow"}));
|
||||
assert_eq!(map["choices"][0]["options"][0]["overrides"], serde_json::json!({}));
|
||||
assert_eq!(map["accent"], true);
|
||||
assert_eq!(map["folderOpen"], "folder-src-open");
|
||||
assert_eq!(d.json["icons"], 7);
|
||||
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust", svg_prefix("m"))));
|
||||
assert!(d.assets.iter().any(|(k, _)| *k == format!("{}rust_dark", svg_prefix("m"))));
|
||||
assert!(d.files.iter().any(|f| f.ends_with("icons/rust-dark.svg")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themes_without_extensions_keep_their_mapping() {
|
||||
let map = map_of(&load(&with("")).unwrap());
|
||||
assert_eq!(map["patterns"], serde_json::json!([]));
|
||||
assert_eq!(map["overrides"], serde_json::json!({}));
|
||||
assert_eq!(map["groups"], serde_json::json!([]));
|
||||
assert_eq!(map["choices"], serde_json::json!([]));
|
||||
assert_eq!(map["accent"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_extensions_are_validated() {
|
||||
let cases = [
|
||||
(r#","patterns":[{"pattern":"x","icon":"nope"}]"#, "unknown icon"),
|
||||
(r#","patterns":[{"pattern":"(a+)+$","icon":"rust"}]"#, "repeated group"),
|
||||
(r#","patterns":[{"pattern":"(","icon":"rust"}]"#, "invalid pattern"),
|
||||
(r#","patterns":[{"pattern":"x","icon":"rust","group":"nope"}]"#, "unknown group"),
|
||||
(r#","patterns":[{"pattern":"x","icon":"rust","openIcon":"tests"}]"#, "openIcon"),
|
||||
(r#","patterns":[{"pattern":"x","icon":"folder","target":"folder","openIcon":"nope"}]"#, "unknown icon"),
|
||||
(r#","patterns":[{"pattern":"x","icon":"rust","priority":1}]"#, "unknown field"),
|
||||
(r#","overrides":{"chevron-right":"nope"}"#, "unknown icon"),
|
||||
(r#","folderOpen":"nope""#, "unknown icon"),
|
||||
(r#","overrides":{"Chevron":"rust"}"#, "invalid overridden icon"),
|
||||
(r#","groups":[{"id":"a","name":" "}]"#, "name"),
|
||||
(r#","groups":[{"id":"a","name":"A"},{"id":"a","name":"B"}]"#, "duplicate"),
|
||||
(r#","choices":[{"id":"c","name":"C","default":"x","options":[{"id":"y","name":"Y"}]}]"#, "default"),
|
||||
(r#","choices":[{"id":"c","name":"C","default":"y","options":[]}]"#, "options"),
|
||||
(
|
||||
r#","choices":[{"id":"c","name":"C","default":"y","options":[{"id":"y","name":"Y","overrides":{"a":"nope"}}]}]"#,
|
||||
"unknown icon",
|
||||
),
|
||||
];
|
||||
for (extra, needle) in cases {
|
||||
let err = load(&with(extra)).err().unwrap_or_else(|| panic!("accepted: {extra}"));
|
||||
assert!(err.contains(needle), "{extra}: {err}");
|
||||
}
|
||||
let dark_id = r#"{"iconDefinitions":{"x_dark":"icons/rust.svg"}}"#;
|
||||
assert!(load(dark_id).unwrap_err().contains("_dark"));
|
||||
}
|
||||
|
||||
const SETTINGS: &str = r##","settings":[
|
||||
{"title":"Toggle","items":[
|
||||
{"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true},
|
||||
{"option":"hollowFolders","label":"Hollow"}]},
|
||||
{"title":"Packs","items":[{"option":"groups","columns":2}]},
|
||||
{"title":"Other","items":[
|
||||
{"option":"choice","choice":"arrows"},
|
||||
{"option":"accent","label":"Accent","default":"#009688"},
|
||||
{"option":"saturation","label":"Saturation","default":10}]}]"##;
|
||||
|
||||
fn settings_of(d: &UiDecl) -> Option<serde_json::Value> {
|
||||
d.assets.iter().find(|(k, _)| *k == settings_asset("m")).map(|(_, v)| serde_json::from_str(v).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_settings_get_their_own_asset() {
|
||||
let d = load(&with(&format!("{EXTENSIONS}{SETTINGS}"))).unwrap();
|
||||
let s = settings_of(&d).unwrap();
|
||||
assert_eq!(s["accent"], true);
|
||||
assert_eq!(s["groups"], serde_json::json!([{"id":"tests","name":"Tests","enabled":false}]));
|
||||
assert_eq!(
|
||||
s["choices"],
|
||||
serde_json::json!([{"id":"arrows","name":"Arrows","default":"plain","options":[
|
||||
{"id":"plain","name":"Default"},{"id":"material","name":"Material"}]}])
|
||||
);
|
||||
assert_eq!(s["settings"][0]["title"], "Toggle");
|
||||
assert_eq!(
|
||||
s["settings"][0]["items"][0],
|
||||
serde_json::json!({"option":"hideFiles","label":"Enable file icons","hint":"Files","invert":true,
|
||||
"choice":null,"columns":null,"default":null})
|
||||
);
|
||||
assert_eq!(s["settings"][2]["items"][1]["default"], "#009688");
|
||||
assert_eq!(s["settings"][2]["items"][2]["default"], 10);
|
||||
assert!(settings_of(&load(&with(EXTENSIONS)).unwrap()).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_settings_are_validated() {
|
||||
let section = |items: &str| with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"T","items":[{items}]}}]"#));
|
||||
let cases = [
|
||||
(section(r#"{"option":"nope","label":"X"}"#), "unknown variant"),
|
||||
(section(r#"{"option":"hideFiles"}"#), "label"),
|
||||
(section(r#"{"option":"hideFiles","label":" "}"#), "label"),
|
||||
(section(r#"{"option":"choice","choice":"nope"}"#), "unknown choice"),
|
||||
(section(r#"{"option":"choice"}"#), "choice"),
|
||||
(section(r#"{"option":"groups","columns":9}"#), "columns"),
|
||||
(section(r#"{"option":"saturation","label":"S"}"#), "default"),
|
||||
(section(r#"{"option":"saturation","label":"S","default":50}"#), "default"),
|
||||
(section(r##"{"option":"opacity","label":"O","default":"#000000"}"##), "default"),
|
||||
(section(r#"{"option":"monochrome","label":"M","default":"red"}"#), "default"),
|
||||
(section(r#"{"option":"saturation","label":"S","default":5,"invert":true}"#), "invert"),
|
||||
(section(r#"{"option":"hideFiles","label":"F","extra":1}"#), "unknown field"),
|
||||
(with(&format!(r#"{EXTENSIONS},"settings":[{{"title":"","items":[]}}]"#)), "title"),
|
||||
(
|
||||
with(r##","settings":[{"title":"T","items":[{"option":"accent","label":"A","default":"#000000"}]}]"##),
|
||||
"accent",
|
||||
),
|
||||
];
|
||||
for (theme, needle) in cases {
|
||||
let err = load(&theme).err().unwrap_or_else(|| panic!("accepted: {theme}"));
|
||||
assert!(err.contains(needle), "{theme}: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,874 @@
|
||||
//! IntelliJ theme plugins, read for import as OpenIDEA theme plugins: a
|
||||
//! Marketplace download (a `.jar`, or a `.zip` holding the plugin's
|
||||
//! `lib/*.jar`), an installed plugin's folder or a loose `.theme.json`; and the
|
||||
//! theme plugins of the JetBrains IDEs on this machine, with the theme each IDE
|
||||
//! is set to (`options/laf.xml`).
|
||||
//!
|
||||
//! Nothing is converted here: the UI maps IntelliJ keys onto design tokens
|
||||
//! (`src/features/plugins/intellijTheme.ts`, shared with `pnpm
|
||||
//! theme:import-intellij`) and [`super::PluginHost::stage_package`] writes the
|
||||
//! result for the install page, which validates it like any package.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::ffi::OsString;
|
||||
use std::io::{Cursor, Read, Seek};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A descriptor, theme or editor scheme larger than this is not one.
|
||||
const MAX_FILE: u64 = 4 << 20;
|
||||
/// A plugin jar read into memory from a Marketplace zip.
|
||||
const MAX_JAR: u64 = 64 << 20;
|
||||
/// Jars of one plugin looked into.
|
||||
const MAX_JARS: usize = 64;
|
||||
/// All the jars read into memory from one Marketplace zip.
|
||||
const MAX_NESTED: u64 = 256 << 20;
|
||||
/// Descriptors read for one plugin: `plugin.xml` and those it includes.
|
||||
const MAX_DESCRIPTORS: usize = 64;
|
||||
const PLUGIN_XML: &str = "META-INF/plugin.xml";
|
||||
|
||||
/// A theme plugin's themes, as read from its files.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntellijThemeSource {
|
||||
/// The plugin's `<name>`, or the theme's for a loose file.
|
||||
pub name: String,
|
||||
pub plugin_id: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub vendor: Option<String>,
|
||||
pub themes: Vec<IntellijThemeFile>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IntellijThemeFile {
|
||||
/// The `themeProvider` id (what `laf.xml` names), or the file name without `.theme.json`.
|
||||
pub id: String,
|
||||
/// The `.theme.json`, comments removed.
|
||||
pub theme: String,
|
||||
/// The editor scheme (`.icls` XML) its `editorScheme` names, when the plugin has it.
|
||||
pub editor_scheme: Option<String>,
|
||||
}
|
||||
|
||||
/// A theme of a plugin installed in a JetBrains IDE.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstalledIntellijTheme {
|
||||
/// The IDE and its version: `IntelliJ IDEA 2025.2`.
|
||||
pub ide: String,
|
||||
/// The plugin's name.
|
||||
pub plugin: String,
|
||||
/// What to import: the plugin's folder or jar.
|
||||
pub path: IpcPath,
|
||||
pub theme_id: String,
|
||||
pub theme_name: String,
|
||||
pub dark: bool,
|
||||
/// The IDE is set to this theme.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// Reads the themes of an IntelliJ theme plugin (a `.jar`, a Marketplace
|
||||
/// `.zip`, an installed plugin's folder) or of a `.theme.json`.
|
||||
pub fn read_source(path: &Path) -> IdeResult<IntellijThemeSource> {
|
||||
read(path).map_err(IdeError::invalid)
|
||||
}
|
||||
|
||||
/// The theme plugins of the JetBrains IDEs (and Android Studio) of this user,
|
||||
/// the themes their IDEs are set to first.
|
||||
pub fn installed_themes() -> Vec<InstalledIntellijTheme> {
|
||||
let Some(home) = dirs::home_dir() else { return Vec::new() };
|
||||
let layout = if cfg!(target_os = "macos") {
|
||||
Layout::Mac
|
||||
} else if cfg!(windows) {
|
||||
Layout::Windows
|
||||
} else {
|
||||
Layout::Xdg
|
||||
};
|
||||
find_installed(&ide_dirs(layout, &home, |key| std::env::var_os(key)))
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> Result<IntellijThemeSource, String> {
|
||||
if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("json")) {
|
||||
return loose_theme(path);
|
||||
}
|
||||
let mut jars = plugin_jars(path)?;
|
||||
let plugin = descriptor(&mut jars)?;
|
||||
let shown = || path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
if plugin.themes.is_empty() {
|
||||
return Err(format!("{} is not a theme plugin: its plugin.xml declares no themeProvider", shown()));
|
||||
}
|
||||
let mut themes = Vec::new();
|
||||
let mut problem = None;
|
||||
for (id, theme_path) in &plugin.themes {
|
||||
match plugin_theme(&mut jars, id, theme_path) {
|
||||
Ok(theme) => themes.push(theme),
|
||||
Err(e) => problem = problem.or(Some(e)),
|
||||
}
|
||||
}
|
||||
if themes.is_empty() {
|
||||
return Err(problem.unwrap_or_else(|| format!("{} has no readable theme", shown())));
|
||||
}
|
||||
Ok(IntellijThemeSource {
|
||||
name: plugin.name.unwrap_or_else(shown),
|
||||
plugin_id: plugin.id,
|
||||
version: plugin.version,
|
||||
vendor: plugin.vendor,
|
||||
themes,
|
||||
})
|
||||
}
|
||||
|
||||
fn plugin_theme(jars: &mut [Jar], id: &str, path: &str) -> Result<IntellijThemeFile, String> {
|
||||
let bytes = find(jars, entry_name(path), MAX_FILE)?.ok_or_else(|| format!("{path} is missing from the plugin"))?;
|
||||
let (theme, fields) = theme_json(&bytes).map_err(|e| format!("{path}: {e}"))?;
|
||||
let editor_scheme = match string_field(&fields, "editorScheme") {
|
||||
Some(scheme) => find(jars, entry_name(scheme), MAX_FILE)?.map(|b| text(&b)),
|
||||
None => None,
|
||||
};
|
||||
Ok(IntellijThemeFile { id: id.to_owned(), theme, editor_scheme })
|
||||
}
|
||||
|
||||
/// A `.theme.json` with the editor scheme beside it (its `editorScheme` names a path in the plugin's jar).
|
||||
fn loose_theme(path: &Path) -> Result<IntellijThemeSource, String> {
|
||||
let (theme, fields) = theme_json(&read_file(path)?).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
// Any JSON object parses: an IntelliJ theme has UI colours or an editor scheme.
|
||||
let scheme_ref = string_field(&fields, "editorScheme");
|
||||
if !fields.get("ui").is_some_and(serde_json::Value::is_object) && scheme_ref.is_none() {
|
||||
return Err(format!("{} is not an IntelliJ theme: it has no \"ui\" or \"editorScheme\"", path.display()));
|
||||
}
|
||||
let editor_scheme = scheme_ref
|
||||
.and_then(|p| Path::new(p).file_name().map(|n| path.with_file_name(n)))
|
||||
.filter(|p| p.is_file())
|
||||
.map(|p| read_file(&p).map(|b| text(&b)))
|
||||
.transpose()?;
|
||||
let file = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
let id = file.strip_suffix(".theme.json").or_else(|| file.strip_suffix(".json")).unwrap_or(&file).to_owned();
|
||||
Ok(IntellijThemeSource {
|
||||
name: string_field(&fields, "name").map_or_else(|| id.clone(), str::to_owned),
|
||||
plugin_id: None,
|
||||
version: None,
|
||||
vendor: string_field(&fields, "author").map(str::to_owned),
|
||||
themes: vec![IntellijThemeFile { id, theme, editor_scheme }],
|
||||
})
|
||||
}
|
||||
|
||||
type JsonObject = serde_json::Map<String, serde_json::Value>;
|
||||
|
||||
/// The theme JSON, comments removed, and its fields, if it is an object.
|
||||
fn theme_json(bytes: &[u8]) -> Result<(String, JsonObject), String> {
|
||||
let json = super::theme::strip_json_comments(&text(bytes));
|
||||
match serde_json::from_str::<serde_json::Value>(&json) {
|
||||
Ok(serde_json::Value::Object(fields)) => Ok((json, fields)),
|
||||
Ok(_) => Err("not a theme: expected a JSON object".into()),
|
||||
Err(e) => Err(format!("invalid theme JSON: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field<'a>(fields: &'a JsonObject, key: &str) -> Option<&'a str> {
|
||||
fields.get(key)?.as_str().filter(|s| !s.trim().is_empty())
|
||||
}
|
||||
|
||||
/// `/themes/x.theme.json` → `themes/x.theme.json`.
|
||||
fn entry_name(path: &str) -> &str {
|
||||
path.trim().trim_start_matches('/')
|
||||
}
|
||||
|
||||
fn text(bytes: &[u8]) -> String {
|
||||
let bytes = bytes.strip_prefix(b"\xEF\xBB\xBF").unwrap_or(bytes);
|
||||
String::from_utf8_lossy(bytes).into_owned()
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<Vec<u8>, String> {
|
||||
let shown = |e: std::io::Error| format!("{}: {e}", path.display());
|
||||
let file = std::fs::File::open(path).map_err(shown)?;
|
||||
let mut buf = Vec::new();
|
||||
file.take(MAX_FILE + 1).read_to_end(&mut buf).map_err(shown)?;
|
||||
if buf.len() as u64 > MAX_FILE {
|
||||
return Err(format!("{} is too large for a theme", path.display()));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
trait ReadSeek: Read + Seek {}
|
||||
impl<T: Read + Seek> ReadSeek for T {}
|
||||
type Jar = zip::ZipArchive<Box<dyn ReadSeek>>;
|
||||
|
||||
fn open_jar(path: &Path) -> Result<Jar, String> {
|
||||
let file = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
let reader: Box<dyn ReadSeek> = Box::new(std::io::BufReader::new(file));
|
||||
zip::ZipArchive::new(reader).map_err(|e| format!("{} is not a jar or zip file ({e})", path.display()))
|
||||
}
|
||||
|
||||
/// The jars of the plugin at `path`: a jar, a Marketplace zip (`<plugin>/lib/*.jar`)
|
||||
/// or an installed plugin's folder (`lib/*.jar`).
|
||||
fn plugin_jars(path: &Path) -> Result<Vec<Jar>, String> {
|
||||
let not_plugin = || format!("{} is not an IntelliJ plugin: no {PLUGIN_XML} and no lib/*.jar", path.display());
|
||||
if path.is_dir() {
|
||||
let mut found: Vec<PathBuf> = [path.join("lib"), path.join("lib").join("modules")]
|
||||
.iter()
|
||||
.flat_map(|dir| std::fs::read_dir(dir).into_iter().flatten().flatten())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && is_jar(p))
|
||||
.collect();
|
||||
found.sort();
|
||||
let jars: Vec<Jar> = found.iter().take(MAX_JARS).filter_map(|p| open_jar(p).ok()).collect();
|
||||
return if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) };
|
||||
}
|
||||
let mut archive = open_jar(path)?;
|
||||
if archive.index_for_name(PLUGIN_XML).is_some() {
|
||||
return Ok(vec![archive]);
|
||||
}
|
||||
let jars = nested_jars(&mut archive, MAX_JAR, MAX_NESTED);
|
||||
if jars.is_empty() { Err(not_plugin()) } else { Ok(jars) }
|
||||
}
|
||||
|
||||
/// The `lib` jars of a Marketplace zip, smallest first (a theme's own jar is small)
|
||||
/// while they fit `max_total`; library jars too large or not jars at all are skipped.
|
||||
fn nested_jars(archive: &mut Jar, max_jar: u64, max_total: u64) -> Vec<Jar> {
|
||||
let mut nested: Vec<(u64, String)> = entry_names(archive)
|
||||
.into_iter()
|
||||
.filter(|n| is_lib_jar(n))
|
||||
.filter_map(|n| Some((archive.by_name(&n).ok()?.size(), n)))
|
||||
.filter(|(size, _)| *size <= max_jar)
|
||||
.collect();
|
||||
nested.sort();
|
||||
let (mut jars, mut loaded) = (Vec::new(), 0u64);
|
||||
for (size, name) in nested.into_iter().take(MAX_JARS) {
|
||||
if loaded.saturating_add(size) > max_total {
|
||||
break;
|
||||
}
|
||||
let Ok(Some(bytes)) = read_entry(archive, &name, max_jar) else { continue };
|
||||
loaded += size;
|
||||
let reader: Box<dyn ReadSeek> = Box::new(Cursor::new(bytes));
|
||||
if let Ok(jar) = zip::ZipArchive::new(reader) {
|
||||
jars.push(jar);
|
||||
}
|
||||
}
|
||||
jars
|
||||
}
|
||||
|
||||
fn entry_names(jar: &Jar) -> Vec<String> {
|
||||
jar.file_names().filter_map(Result::ok).map(|n| n.into_owned()).collect()
|
||||
}
|
||||
|
||||
fn is_jar(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|e| e.eq_ignore_ascii_case("jar"))
|
||||
}
|
||||
|
||||
/// `lib/x.jar`, `<plugin>/lib/x.jar` or `<plugin>/lib/modules/x.jar` in a Marketplace zip.
|
||||
fn is_lib_jar(name: &str) -> bool {
|
||||
let parts: Vec<&str> = name.split('/').collect();
|
||||
let n = parts.len();
|
||||
name.to_ascii_lowercase().ends_with(".jar")
|
||||
&& (2..=4).contains(&n)
|
||||
&& (parts[n - 2] == "lib" || (n >= 3 && parts[n - 2] == "modules" && parts[n - 3] == "lib"))
|
||||
}
|
||||
|
||||
fn read_entry(jar: &mut Jar, name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
let Ok(file) = jar.by_name(name) else { return Ok(None) };
|
||||
if file.is_dir() {
|
||||
return Ok(None);
|
||||
}
|
||||
if file.size() > max {
|
||||
return Err(format!("{name} is too large"));
|
||||
}
|
||||
let mut buf = Vec::with_capacity(file.size() as usize);
|
||||
file.take(max.saturating_add(1)).read_to_end(&mut buf).map_err(|e| format!("{name}: {e}"))?;
|
||||
if buf.len() as u64 > max {
|
||||
return Err(format!("{name} is too large"));
|
||||
}
|
||||
Ok(Some(buf))
|
||||
}
|
||||
|
||||
fn find(jars: &mut [Jar], name: &str, max: u64) -> Result<Option<Vec<u8>>, String> {
|
||||
for jar in jars.iter_mut() {
|
||||
if let Some(bytes) = read_entry(jar, name, max)? {
|
||||
return Ok(Some(bytes));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// What the plugin's descriptors say: `<idea-plugin>`'s `id`, `name`, `version`,
|
||||
/// `vendor`, and its `themeProvider`s (id, path in the jar).
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
struct Descriptor {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
vendor: Option<String>,
|
||||
themes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Reads `META-INF/plugin.xml` and the descriptors it includes (`<xi:include href>`), in any jar.
|
||||
fn descriptor(jars: &mut [Jar]) -> Result<Descriptor, String> {
|
||||
let main = find(jars, PLUGIN_XML, MAX_FILE)
|
||||
.ok()
|
||||
.flatten()
|
||||
.ok_or_else(|| format!("not an IntelliJ plugin: no {PLUGIN_XML}"))?;
|
||||
let mut out = Descriptor::default();
|
||||
let mut includes = Vec::new();
|
||||
parse_descriptor(&text(&main), true, &mut out, &mut includes).map_err(|e| format!("{PLUGIN_XML}: {e}"))?;
|
||||
let mut read = std::collections::HashSet::from([PLUGIN_XML.to_owned()]);
|
||||
while let Some(href) = includes.pop() {
|
||||
let name = include_entry(&href);
|
||||
if read.len() >= MAX_DESCRIPTORS || !read.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
let Ok(Some(bytes)) = find(jars, &name, MAX_FILE) else { continue };
|
||||
let _ = parse_descriptor(&text(&bytes), false, &mut out, &mut includes);
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.themes.retain(|(id, _)| seen.insert(id.clone()));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// An `<xi:include href>` as a jar entry: `/META-INF/x.xml` from the jar's root, `x.xml` beside plugin.xml.
|
||||
fn include_entry(href: &str) -> String {
|
||||
let href = href.trim();
|
||||
href.strip_prefix('/').map_or_else(|| format!("META-INF/{href}"), str::to_owned)
|
||||
}
|
||||
|
||||
fn parse_descriptor(xml: &str, main: bool, out: &mut Descriptor, includes: &mut Vec<String>) -> Result<(), String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut stack: Vec<String> = Vec::new();
|
||||
let mut text = String::new();
|
||||
loop {
|
||||
let event = reader.read_event().map_err(|e| e.to_string())?;
|
||||
match event {
|
||||
Event::Start(ref tag) | Event::Empty(ref tag) => {
|
||||
let name = tag.local_name().as_ref().to_owned();
|
||||
if name == "include" {
|
||||
let href = tag.attributes().flatten().find(|a| a.key.local_name().as_ref() == "href");
|
||||
if let Some(href) = href.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok()) {
|
||||
includes.push(href.into_owned());
|
||||
}
|
||||
}
|
||||
if name == "themeProvider" {
|
||||
let (mut id, mut path) = (None, None);
|
||||
for a in tag.attributes() {
|
||||
let a = a.map_err(|e| e.to_string())?;
|
||||
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
|
||||
match a.key.local_name().as_ref() {
|
||||
"id" => id = Some(value.trim().to_owned()),
|
||||
"path" => path = Some(value.trim().to_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let (Some(id), Some(path)) = (id, path)
|
||||
&& !id.is_empty()
|
||||
&& !path.is_empty()
|
||||
{
|
||||
out.themes.push((id, path));
|
||||
}
|
||||
}
|
||||
if matches!(event, Event::Start(_)) {
|
||||
stack.push(name);
|
||||
text.clear();
|
||||
}
|
||||
}
|
||||
Event::Text(t) => text.push_str(&t.xml_content(XmlVersion::Implicit1_0)),
|
||||
Event::CData(t) => text.push_str(t.as_ref()),
|
||||
Event::GeneralRef(r) => {
|
||||
let name = r.as_ref();
|
||||
let escaped = format!("&{name};");
|
||||
text.push_str(&quick_xml::escape::unescape(&escaped).map_err(|e| e.to_string())?);
|
||||
}
|
||||
Event::End(_) => {
|
||||
let name = stack.pop().ok_or("unbalanced element")?;
|
||||
// Direct children of <idea-plugin>.
|
||||
if main && stack.len() == 1 && stack[0] == "idea-plugin" {
|
||||
let value = text.trim();
|
||||
let slot = match name.as_str() {
|
||||
"id" => Some(&mut out.id),
|
||||
"name" => Some(&mut out.name),
|
||||
"version" => Some(&mut out.version),
|
||||
"vendor" => Some(&mut out.vendor),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(slot) = slot
|
||||
&& slot.is_none()
|
||||
&& !value.is_empty()
|
||||
{
|
||||
*slot = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
text.clear();
|
||||
}
|
||||
Event::Eof => return if stack.is_empty() { Ok(()) } else { Err("unclosed element".into()) },
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where an OS keeps the IDEs' settings and plugins.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum Layout {
|
||||
/// `~/Library/Application Support/<vendor>/<IDE>`, plugins in its `plugins/`.
|
||||
Mac,
|
||||
/// `%APPDATA%\<vendor>\<IDE>`, plugins in its `plugins\`.
|
||||
Windows,
|
||||
/// `$XDG_CONFIG_HOME/<vendor>/<IDE>`, plugins in `$XDG_DATA_HOME/<vendor>/<IDE>`.
|
||||
Xdg,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
struct IdeDir {
|
||||
label: String,
|
||||
product: String,
|
||||
version: (u32, u32),
|
||||
config: PathBuf,
|
||||
plugins: PathBuf,
|
||||
}
|
||||
|
||||
/// The settings folders of JetBrains IDEs and Android Studio (2020.1 and later), newest first.
|
||||
fn ide_dirs(layout: Layout, home: &Path, env: impl Fn(&str) -> Option<OsString>) -> Vec<IdeDir> {
|
||||
let var = |key: &str| env(key).filter(|v| !v.is_empty()).map(PathBuf::from);
|
||||
let (config_root, data_root) = match layout {
|
||||
Layout::Mac => {
|
||||
let root = home.join("Library").join("Application Support");
|
||||
(root.clone(), root)
|
||||
}
|
||||
Layout::Windows => {
|
||||
let root = var("APPDATA").unwrap_or_else(|| home.join("AppData").join("Roaming"));
|
||||
(root.clone(), root)
|
||||
}
|
||||
Layout::Xdg => (
|
||||
var("XDG_CONFIG_HOME").unwrap_or_else(|| home.join(".config")),
|
||||
var("XDG_DATA_HOME").unwrap_or_else(|| home.join(".local").join("share")),
|
||||
),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for vendor in ["JetBrains", "Google"] {
|
||||
for entry in std::fs::read_dir(config_root.join(vendor)).into_iter().flatten().flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let Some((product, version, shown)) = product_dir(&name) else { continue };
|
||||
let config = entry.path();
|
||||
if !config.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let plugins = match layout {
|
||||
Layout::Xdg => data_root.join(vendor).join(&name),
|
||||
Layout::Mac | Layout::Windows => config.join("plugins"),
|
||||
};
|
||||
out.push(IdeDir {
|
||||
label: format!("{} {shown}", product_label(product)),
|
||||
product: product.to_owned(),
|
||||
version,
|
||||
config,
|
||||
plugins,
|
||||
});
|
||||
}
|
||||
}
|
||||
out.sort_by(|a, b| b.version.cmp(&a.version).then_with(|| a.label.cmp(&b.label)));
|
||||
out
|
||||
}
|
||||
|
||||
/// `IntelliJIdea2025.2` → (`IntelliJIdea`, (2025, 2), `2025.2`).
|
||||
fn product_dir(name: &str) -> Option<(&str, (u32, u32), &str)> {
|
||||
let at = name.find(|c: char| c.is_ascii_digit())?;
|
||||
let (product, version) = name.split_at(at);
|
||||
if product.is_empty() || !product.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
let (year, minor) = version.split_once('.')?;
|
||||
let ok = |s: &str| !s.is_empty() && s.len() <= 4 && s.chars().all(|c| c.is_ascii_digit());
|
||||
if year.len() != 4 || !ok(year) || !ok(minor) {
|
||||
return None;
|
||||
}
|
||||
Some((product, (year.parse().ok()?, minor.parse().ok()?), version))
|
||||
}
|
||||
|
||||
fn product_label(product: &str) -> &str {
|
||||
match product {
|
||||
"IntelliJIdea" => "IntelliJ IDEA",
|
||||
"IdeaIC" => "IntelliJ IDEA Community Edition",
|
||||
"PyCharmCE" => "PyCharm Community Edition",
|
||||
"AndroidStudio" => "Android Studio",
|
||||
"AndroidStudioPreview" => "Android Studio Preview",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The theme ids `options/laf.xml` selects: the current one, and with "Sync with OS" the preferred light and dark.
|
||||
fn active_theme_ids(config: &Path) -> Vec<String> {
|
||||
let Ok(bytes) = read_file(&config.join("options").join("laf.xml")) else { return Vec::new() };
|
||||
let xml = text(&bytes);
|
||||
let mut reader = Reader::from_str(&xml);
|
||||
let (mut ids, mut preferred, mut autodetect) = (Vec::new(), Vec::new(), false);
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(tag) | Event::Empty(tag)) => {
|
||||
let attr = |key: &str| {
|
||||
tag.attributes()
|
||||
.flatten()
|
||||
.find(|a| a.key.as_ref() == key)
|
||||
.and_then(|a| a.normalized_value(XmlVersion::Implicit1_0).ok().map(|v| v.trim().to_owned()))
|
||||
};
|
||||
match tag.local_name().as_ref() {
|
||||
"component" if attr("name").as_deref() == Some("LafManager") => {
|
||||
autodetect = attr("autodetect").as_deref() == Some("true");
|
||||
}
|
||||
"laf" => ids.extend(attr("themeId")),
|
||||
"preferred-light-laf" | "preferred-dark-laf" => preferred.extend(attr("themeId")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Event::Eof) | Err(_) => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if autodetect {
|
||||
ids.extend(preferred);
|
||||
}
|
||||
ids.retain(|id| !id.is_empty());
|
||||
ids
|
||||
}
|
||||
|
||||
fn find_installed(ides: &[IdeDir]) -> Vec<InstalledIntellijTheme> {
|
||||
let mut out = Vec::new();
|
||||
for ide in ides {
|
||||
let active = active_theme_ids(&ide.config);
|
||||
let mut plugins: Vec<PathBuf> = std::fs::read_dir(&ide.plugins)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir() || (p.is_file() && is_jar(p)))
|
||||
.collect();
|
||||
plugins.sort();
|
||||
for plugin in plugins {
|
||||
let Ok(mut jars) = plugin_jars(&plugin) else { continue };
|
||||
let Ok(found) = descriptor(&mut jars) else { continue };
|
||||
for (id, path) in &found.themes {
|
||||
let Ok(Some(bytes)) = find(&mut jars, entry_name(path), MAX_FILE) else { continue };
|
||||
let Ok((_, fields)) = theme_json(&bytes) else { continue };
|
||||
let file = || plugin.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
out.push(InstalledIntellijTheme {
|
||||
ide: ide.label.clone(),
|
||||
plugin: found.name.clone().unwrap_or_else(file),
|
||||
path: plugin.clone().into(),
|
||||
theme_id: id.clone(),
|
||||
theme_name: string_field(&fields, "name").unwrap_or(id).to_owned(),
|
||||
dark: fields.get("dark").and_then(serde_json::Value::as_bool).unwrap_or(true),
|
||||
active: active.contains(id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// Stable: the IDEs' order (newest first) within each group.
|
||||
out.sort_by_key(|t| !t.active);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
const PLUGIN: &str = r#"<idea-plugin>
|
||||
<id>com.example.deepsea</id>
|
||||
<name>Deep Sea & Friends</name>
|
||||
<version>2.3</version>
|
||||
<vendor email="[email protected]">Someone</vendor>
|
||||
<description><![CDATA[<b>Dark</b> themes]]></description>
|
||||
<depends>com.intellij.modules.platform</depends>
|
||||
<extensions defaultExtensionNs="com.intellij">
|
||||
<themeProvider id="deep-sea-id" path="/themes/DeepSea.theme.json"/>
|
||||
<themeProvider id="shallow-id" path="themes/Shallow.theme.json" />
|
||||
</extensions>
|
||||
</idea-plugin>"#;
|
||||
|
||||
const DEEP: &str = r##"{
|
||||
// comment
|
||||
"name": "Deep Sea",
|
||||
"dark": true,
|
||||
"editorScheme": "/themes/DeepSea.xml",
|
||||
"ui": { "*": { "background": "#101820" } }
|
||||
}"##;
|
||||
const SHALLOW: &str = r#"{ "name": "Shallow", "dark": false, "ui": {} }"#;
|
||||
const SCHEME: &str = r#"<scheme name="Deep Sea" version="142" parent_scheme="Darcula"/>"#;
|
||||
|
||||
fn jar_bytes(entries: &[(&str, &[u8])]) -> Vec<u8> {
|
||||
let mut zip = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
for (name, data) in entries {
|
||||
zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(data).unwrap();
|
||||
}
|
||||
zip.finish().unwrap().into_inner()
|
||||
}
|
||||
|
||||
fn theme_jar() -> Vec<u8> {
|
||||
jar_bytes(&[
|
||||
("META-INF/MANIFEST.MF", b"Manifest-Version: 1.0\n"),
|
||||
(PLUGIN_XML, PLUGIN.as_bytes()),
|
||||
("themes/DeepSea.theme.json", DEEP.as_bytes()),
|
||||
("themes/DeepSea.xml", SCHEME.as_bytes()),
|
||||
("themes/Shallow.theme.json", SHALLOW.as_bytes()),
|
||||
])
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
fn expected_source() -> IntellijThemeSource {
|
||||
IntellijThemeSource {
|
||||
name: "Deep Sea & Friends".into(),
|
||||
plugin_id: Some("com.example.deepsea".into()),
|
||||
version: Some("2.3".into()),
|
||||
vendor: Some("Someone".into()),
|
||||
themes: vec![
|
||||
IntellijThemeFile {
|
||||
id: "deep-sea-id".into(),
|
||||
theme: super::super::theme::strip_json_comments(DEEP),
|
||||
editor_scheme: Some(SCHEME.into()),
|
||||
},
|
||||
IntellijThemeFile { id: "shallow-id".into(), theme: SHALLOW.into(), editor_scheme: None },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_theme_jar_with_its_editor_scheme() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jar = tmp.path().join("deep-sea.jar");
|
||||
write(&jar, &theme_jar());
|
||||
let source = read_source(&jar).unwrap();
|
||||
assert_eq!(source, expected_source());
|
||||
assert!(!source.themes[0].theme.contains("comment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_marketplace_zip_and_an_installed_folder() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let other = jar_bytes(&[("META-INF/MANIFEST.MF", b"x"), ("a/B.class", b"\xCA\xFE")]);
|
||||
let zip = tmp.path().join("DeepSea-2.3.zip");
|
||||
write(&zip, &jar_bytes(&[("DeepSea/lib/a-util.jar", &other), ("DeepSea/lib/deep-sea.jar", &theme_jar())]));
|
||||
assert_eq!(read_source(&zip).unwrap(), expected_source());
|
||||
|
||||
let dir = tmp.path().join("plugins").join("DeepSea");
|
||||
write(&dir.join("lib").join("deep-sea.jar"), &theme_jar());
|
||||
write(&dir.join("lib").join("a-util.jar"), &other);
|
||||
assert_eq!(read_source(&dir).unwrap(), expected_source());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_library_jars_it_cannot_or_need_not_read() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
// Stored, not deflated: as large in the zip as its content.
|
||||
let mut big = zip::ZipWriter::new(Cursor::new(Vec::new()));
|
||||
let stored = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Stored);
|
||||
big.start_file("lib/Data.class", stored).unwrap();
|
||||
big.write_all(&[b'x'; 8192]).unwrap();
|
||||
let big = big.finish().unwrap().into_inner();
|
||||
let zip = tmp.path().join("DeepSea.zip");
|
||||
write(
|
||||
&zip,
|
||||
&jar_bytes(&[
|
||||
("DeepSea/lib/a-broken.jar", b"not a jar"),
|
||||
("DeepSea/lib/b-big.jar", &big),
|
||||
("DeepSea/lib/deep-sea.jar", &theme_jar()),
|
||||
]),
|
||||
);
|
||||
assert_eq!(read_source(&zip).unwrap(), expected_source());
|
||||
|
||||
// The broken jar is skipped; a jar over the size limit, or past the total
|
||||
// (smallest first), is not read, while the theme's small jar still is.
|
||||
let mut archive = open_jar(&zip).unwrap();
|
||||
let theme_len = theme_jar().len() as u64;
|
||||
assert_eq!(nested_jars(&mut archive, u64::MAX, u64::MAX).len(), 2);
|
||||
let one = nested_jars(&mut archive, theme_len + 64, u64::MAX);
|
||||
assert_eq!(one.len(), 1);
|
||||
assert!(one[0].index_for_name(PLUGIN_XML).is_some());
|
||||
assert_eq!(nested_jars(&mut archive, u64::MAX, theme_len + 64).len(), 1);
|
||||
assert!(nested_jars(&mut archive, 8, u64::MAX).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_a_loose_theme_json_with_the_scheme_beside_it() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let theme = tmp.path().join("DeepSea.theme.json");
|
||||
write(&theme, DEEP.as_bytes());
|
||||
write(&tmp.path().join("DeepSea.xml"), SCHEME.as_bytes());
|
||||
let source = read_source(&theme).unwrap();
|
||||
assert_eq!(source.name, "Deep Sea");
|
||||
assert_eq!(source.themes.len(), 1);
|
||||
assert_eq!(source.themes[0].id, "DeepSea");
|
||||
assert_eq!(source.themes[0].editor_scheme.as_deref(), Some(SCHEME));
|
||||
|
||||
std::fs::remove_file(tmp.path().join("DeepSea.xml")).unwrap();
|
||||
assert_eq!(read_source(&theme).unwrap().themes[0].editor_scheme, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn says_what_is_wrong_with_other_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let not_zip = tmp.path().join("notes.jar");
|
||||
write(¬_zip, b"hello");
|
||||
assert!(read_source(¬_zip).unwrap_err().message.contains("not a jar or zip"));
|
||||
|
||||
let other_plugin = tmp.path().join("tools.jar");
|
||||
let xml = "<idea-plugin><name>Tools</name><actions/></idea-plugin>";
|
||||
write(&other_plugin, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
|
||||
assert!(read_source(&other_plugin).unwrap_err().message.contains("not a theme plugin"));
|
||||
|
||||
let package = tmp.path().join("package.zip");
|
||||
write(&package, &jar_bytes(&[("plugin.toml", b"id = \"x\"")]));
|
||||
assert!(read_source(&package).unwrap_err().message.contains("not an IntelliJ plugin"));
|
||||
|
||||
let other = tmp.path().join("package.json");
|
||||
write(&other, br#"{ "name": "app", "version": "1.0.0" }"#);
|
||||
assert!(read_source(&other).unwrap_err().message.contains("is not an IntelliJ theme"));
|
||||
|
||||
let broken = tmp.path().join("broken.theme.json");
|
||||
write(&broken, b"{ \"name\": ");
|
||||
assert!(read_source(&broken).unwrap_err().message.contains("invalid theme JSON"));
|
||||
|
||||
let missing = tmp.path().join("missing.jar");
|
||||
let xml = r#"<idea-plugin><name>M</name><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
|
||||
write(&missing, &jar_bytes(&[(PLUGIN_XML, xml.as_bytes())]));
|
||||
assert!(read_source(&missing).unwrap_err().message.contains("/m.theme.json is missing"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_theme_providers_in_included_descriptors() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let jar = tmp.path().join("split.jar");
|
||||
let main = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><name>Split</name>
|
||||
<xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
|
||||
let part = r#"<idea-plugin xmlns:xi="http://www.w3.org/2001/XInclude"><extensions defaultExtensionNs="com.intellij">
|
||||
<themeProvider id="s" path="/s.theme.json"/></extensions><name>Not this</name>
|
||||
<xi:include href="more.xml"/><xi:include href="/META-INF/themes.xml"/></idea-plugin>"#;
|
||||
let more = r#"<idea-plugin><themeProvider id="m" path="/m.theme.json"/></idea-plugin>"#;
|
||||
let unrelated = r#"<idea-plugin><themeProvider id="x" path="/x.theme.json"/></idea-plugin>"#;
|
||||
write(
|
||||
&jar,
|
||||
&jar_bytes(&[
|
||||
("META-INF/themes.xml", part.as_bytes()),
|
||||
("META-INF/more.xml", more.as_bytes()),
|
||||
("META-INF/unrelated.xml", unrelated.as_bytes()),
|
||||
(PLUGIN_XML, main.as_bytes()),
|
||||
("s.theme.json", br#"{"name":"S"}"#),
|
||||
("m.theme.json", br#"{"name":"M"}"#),
|
||||
("x.theme.json", br#"{"name":"X"}"#),
|
||||
]),
|
||||
);
|
||||
let source = read_source(&jar).unwrap();
|
||||
assert_eq!(source.name, "Split");
|
||||
// Only what plugin.xml includes (relative or from the root, once each) counts.
|
||||
assert_eq!(source.themes.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(), ["s", "m"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_ide_folder_names() {
|
||||
assert_eq!(product_dir("IntelliJIdea2025.2"), Some(("IntelliJIdea", (2025, 2), "2025.2")));
|
||||
assert_eq!(product_dir("PyCharmCE2023.10"), Some(("PyCharmCE", (2023, 10), "2023.10")));
|
||||
assert_eq!(product_dir("AndroidStudio2024.3"), Some(("AndroidStudio", (2024, 3), "2024.3")));
|
||||
for name in
|
||||
["Toolbox", "consentOptions", "IntelliJIdea2025", "Idea-2025.1", "2025.1", "Rider25.1", "Go Land2025.1"]
|
||||
{
|
||||
assert_eq!(product_dir(name), None, "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locates_the_settings_and_plugins_of_each_os() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path();
|
||||
let mk = |p: PathBuf| {
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
p
|
||||
};
|
||||
let mac = mk(home.join("Library/Application Support/JetBrains/IntelliJIdea2025.2"));
|
||||
mk(home.join("Library/Application Support/JetBrains/Toolbox"));
|
||||
let dirs = ide_dirs(Layout::Mac, home, |_| None);
|
||||
assert_eq!(dirs.len(), 1);
|
||||
assert_eq!(dirs[0].label, "IntelliJ IDEA 2025.2");
|
||||
assert_eq!((dirs[0].config.clone(), dirs[0].plugins.clone()), (mac.clone(), mac.join("plugins")));
|
||||
|
||||
let appdata = mk(home.join("Roaming"));
|
||||
let win = mk(appdata.join("JetBrains/PyCharm2024.3"));
|
||||
let studio = mk(appdata.join("Google/AndroidStudio2025.1"));
|
||||
let env = |k: &str| (k == "APPDATA").then(|| appdata.clone().into_os_string());
|
||||
let dirs = ide_dirs(Layout::Windows, home, env);
|
||||
assert_eq!(
|
||||
dirs.iter().map(|d| d.label.as_str()).collect::<Vec<_>>(),
|
||||
["Android Studio 2025.1", "PyCharm 2024.3"]
|
||||
);
|
||||
assert_eq!(dirs[1].plugins, win.join("plugins"));
|
||||
assert_eq!(dirs[0].config, studio);
|
||||
|
||||
let config = mk(home.join(".config/JetBrains/IdeaIC2024.1"));
|
||||
mk(home.join(".config/JetBrains/GoLand2025.2"));
|
||||
let dirs = ide_dirs(Layout::Xdg, home, |_| None);
|
||||
assert_eq!(dirs.iter().map(|d| d.product.as_str()).collect::<Vec<_>>(), ["GoLand", "IdeaIC"]);
|
||||
assert_eq!(dirs[1].label, "IntelliJ IDEA Community Edition 2024.1");
|
||||
assert_eq!(dirs[1].config, config);
|
||||
assert_eq!(dirs[1].plugins, home.join(".local/share/JetBrains/IdeaIC2024.1"));
|
||||
|
||||
let xdg_data = home.join("data");
|
||||
let env = |k: &str| (k == "XDG_DATA_HOME").then(|| xdg_data.clone().into_os_string());
|
||||
assert_eq!(ide_dirs(Layout::Xdg, home, env)[1].plugins, xdg_data.join("JetBrains/IdeaIC2024.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_selected_theme_from_laf_xml() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let laf = tmp.path().join("options/laf.xml");
|
||||
write(
|
||||
&laf,
|
||||
br#"<application>
|
||||
<component name="LafManager">
|
||||
<laf class-name="com.intellij.ide.ui.laf.UIThemeBasedLookAndFeelInfo" themeId="deep-sea-id" />
|
||||
<preferred-light-laf themeId="shallow-id" />
|
||||
</component>
|
||||
</application>"#,
|
||||
);
|
||||
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id"]);
|
||||
let synced = std::fs::read_to_string(&laf)
|
||||
.unwrap()
|
||||
.replace(r#"name="LafManager""#, r#"name="LafManager" autodetect="true""#);
|
||||
write(&laf, synced.as_bytes());
|
||||
assert_eq!(active_theme_ids(tmp.path()), ["deep-sea-id", "shallow-id"]);
|
||||
assert!(active_theme_ids(&tmp.path().join("missing")).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lists_installed_theme_plugins_the_ides_current_first() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("IntelliJIdea2025.2");
|
||||
let plugins = config.join("plugins");
|
||||
write(&plugins.join("DeepSea/lib/deep-sea.jar"), &theme_jar());
|
||||
write(
|
||||
&plugins.join("Tools/lib/tools.jar"),
|
||||
&jar_bytes(&[(PLUGIN_XML, b"<idea-plugin><name>T</name></idea-plugin>")]),
|
||||
);
|
||||
write(&plugins.join("broken.jar"), b"not a jar");
|
||||
write(
|
||||
&config.join("options/laf.xml"),
|
||||
br#"<application><component name="LafManager"><laf themeId="shallow-id"/></component></application>"#,
|
||||
);
|
||||
let ide = IdeDir {
|
||||
label: "IntelliJ IDEA 2025.2".into(),
|
||||
product: "IntelliJIdea".into(),
|
||||
version: (2025, 2),
|
||||
config: config.clone(),
|
||||
plugins: plugins.clone(),
|
||||
};
|
||||
let found = find_installed(&[ide]);
|
||||
let theme = |id: &str, name: &str, dark: bool, active: bool| InstalledIntellijTheme {
|
||||
ide: "IntelliJ IDEA 2025.2".into(),
|
||||
plugin: "Deep Sea & Friends".into(),
|
||||
path: plugins.join("DeepSea").into(),
|
||||
theme_id: id.into(),
|
||||
theme_name: name.into(),
|
||||
dark,
|
||||
active,
|
||||
};
|
||||
assert_eq!(found, [theme("shallow-id", "Shallow", false, true), theme("deep-sea-id", "Deep Sea", true, false)]);
|
||||
assert!(find_installed(&[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,14 @@
|
||||
//! plugins, and revocations. The host keeps the last index it read
|
||||
//! (`market-index.json`), checks signatures against it, and disables what it
|
||||
//! revokes — at start-up and on every refresh (the desktop refreshes daily).
|
||||
//! Release builds pin the official publisher to the keys they were built
|
||||
//! with, so no index can sign `openidea.*` plugins with another key.
|
||||
|
||||
use super::{PluginHost, PluginSource, PluginStatus};
|
||||
use super::{PackageFile, PluginEvent, PluginHost, PluginSource, PluginStatus, PluginUpdate, install};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_extension::Manifest;
|
||||
use ide_extension::package::{self, Index, IndexEntry, PackageSignature, SIGNATURE_FILE};
|
||||
use ide_extension::{MANIFEST_FILE, Manifest};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -15,11 +18,18 @@ use std::path::{Path, PathBuf};
|
||||
/// the app, next to the signed packages of the official plugins.
|
||||
pub const OFFICIAL_INDEX: &str = "https://github.com/l0ng-ai/openidea/releases/latest/download/plugins-index.json";
|
||||
|
||||
/// The publisher of the official plugins.
|
||||
pub const OFFICIAL_PUBLISHER: &str = "openidea";
|
||||
|
||||
/// The last index read, in the plugins directory.
|
||||
pub const INDEX_FILE: &str = "market-index.json";
|
||||
const DOWNLOADS: &str = ".downloads";
|
||||
const MAX_INDEX: usize = 16 << 20;
|
||||
const MAX_PACKAGE: usize = 256 << 20;
|
||||
const MAX_STAGED_FILES: usize = 256;
|
||||
const MAX_STAGED_BYTES: usize = 16 << 20;
|
||||
/// `moved` links followed before giving up (a loop, or a misconfiguration).
|
||||
const MAX_MOVES: usize = 3;
|
||||
|
||||
/// A marketplace entry with what is installed.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, specta::Type)]
|
||||
@@ -29,6 +39,32 @@ pub struct MarketPlugin {
|
||||
pub entry: IndexEntry,
|
||||
/// Installed version, if any.
|
||||
pub installed: Option<String>,
|
||||
/// Whether this IDE can load `entry` (the newest version it can, else the newest).
|
||||
pub compatible: bool,
|
||||
}
|
||||
|
||||
impl MarketPlugin {
|
||||
/// A version this IDE can load, newer than the one installed.
|
||||
pub fn is_update(&self) -> bool {
|
||||
let newer = |installed: &String| match (Version::parse(&self.entry.version), Version::parse(installed)) {
|
||||
(Ok(offered), Ok(installed)) => offered > installed,
|
||||
_ => false,
|
||||
};
|
||||
self.compatible && self.installed.as_ref().is_some_and(newer)
|
||||
}
|
||||
}
|
||||
|
||||
/// The official publisher's keys this IDE was built with
|
||||
/// (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`: base64, comma-separated). Development
|
||||
/// builds have none: the index decides.
|
||||
pub(super) fn built_in_official_keys() -> Vec<String> {
|
||||
let keys = option_env!("OPENIDEA_OFFICIAL_PLUGIN_KEYS").unwrap_or_default();
|
||||
keys.split(',').map(str::trim).filter(|k| !k.is_empty()).map(str::to_owned).collect()
|
||||
}
|
||||
|
||||
fn pinned(mut index: Index, official_keys: &[String]) -> Index {
|
||||
index.pin_publisher(OFFICIAL_PUBLISHER, "OpenIDEA", official_keys);
|
||||
index
|
||||
}
|
||||
|
||||
/// Reads `url`: `http(s)://…`, `file://…` or a local path.
|
||||
@@ -56,6 +92,33 @@ pub fn read_url(url: &str, max: usize) -> Result<Vec<u8>, String> {
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The index at `url`, following `moved`, with downloads made absolute against
|
||||
/// where it was read from.
|
||||
fn read_index(url: &str) -> IdeResult<Index> {
|
||||
let mut url = url.to_owned();
|
||||
for _ in 0..=MAX_MOVES {
|
||||
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
|
||||
let mut index = Index::parse(&text).map_err(IdeError::invalid)?;
|
||||
match index.moved.take().filter(|m| !m.trim().is_empty()) {
|
||||
Some(next) => {
|
||||
let next = resolve(&url, &next);
|
||||
if url.starts_with("https://") && !next.starts_with("https://") {
|
||||
return Err(IdeError::invalid(format!("the marketplace moved to {next}, which isn't https")));
|
||||
}
|
||||
url = next;
|
||||
}
|
||||
None => {
|
||||
for e in &mut index.plugins {
|
||||
e.download = resolve(&url, &e.download);
|
||||
}
|
||||
return Ok(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(IdeError::unavailable(format!("the marketplace index moved more than {MAX_MOVES} times")))
|
||||
}
|
||||
|
||||
/// `href` relative to the index at `base`: a URL, or a local path (with the
|
||||
/// platform's separators, e.g. `C:\\market\\index.json`).
|
||||
pub fn resolve(base: &str, href: &str) -> String {
|
||||
@@ -75,9 +138,16 @@ pub fn resolve(base: &str, href: &str) -> String {
|
||||
}
|
||||
|
||||
impl PluginHost {
|
||||
pub(super) fn load_market_index(dir: &Path) -> Option<Index> {
|
||||
pub(super) fn load_market_index(dir: &Path, official_keys: &[String]) -> Option<Index> {
|
||||
let text = std::fs::read_to_string(dir.join(INDEX_FILE)).ok()?;
|
||||
Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()
|
||||
let index = Index::parse(&text).map_err(|e| tracing::warn!("{INDEX_FILE}: {e}")).ok()?;
|
||||
Some(pinned(index, official_keys))
|
||||
}
|
||||
|
||||
/// Pins the official publisher to `keys` (base64) instead of the built-in
|
||||
/// ones, from the next index read.
|
||||
pub fn set_official_keys(&self, keys: Vec<String>) {
|
||||
*self.official_keys.write() = keys;
|
||||
}
|
||||
|
||||
/// The marketplace index URL: the user's, else the default (none: no marketplace).
|
||||
@@ -95,14 +165,16 @@ impl PluginHost {
|
||||
self.save_state();
|
||||
}
|
||||
|
||||
/// Reads the index again, keeps it, and applies its revocations.
|
||||
/// Reads the index again, keeps it, and applies its revocations. Announces
|
||||
/// updates of installed plugins it hasn't announced yet.
|
||||
pub fn refresh_market(&self) -> IdeResult<Vec<MarketPlugin>> {
|
||||
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
|
||||
let bytes = read_url(&url, MAX_INDEX).map_err(IdeError::unavailable)?;
|
||||
let text = String::from_utf8(bytes).map_err(|_| IdeError::invalid("the index is not UTF-8"))?;
|
||||
let index = Index::parse(&text).map_err(IdeError::invalid)?;
|
||||
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), &text);
|
||||
*self.market.write() = Some(index);
|
||||
let index = read_index(&url)?;
|
||||
if let Ok(json) = serde_json::to_string(&index) {
|
||||
let _ = std::fs::write(self.plugins_dir.join(INDEX_FILE), json);
|
||||
}
|
||||
let newer = index.is_newer().then_some(index.format);
|
||||
*self.market.write() = Some(pinned(index, &self.official_keys.read()));
|
||||
// Trust may have changed: who signed what, what is revoked.
|
||||
let installed: Vec<_> =
|
||||
self.plugins.read().values().filter(|p| p.source == PluginSource::Installed).cloned().collect();
|
||||
@@ -111,7 +183,41 @@ impl PluginHost {
|
||||
plugin.meta.lock().publisher = publisher;
|
||||
}
|
||||
self.apply_revocations();
|
||||
Ok(self.market_plugins())
|
||||
if let Some(format) = newer {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"the marketplace needs a newer OpenIDEA (index format {format}); its revocations still apply"
|
||||
)));
|
||||
}
|
||||
let plugins = self.market_plugins();
|
||||
self.announce_updates(&plugins);
|
||||
Ok(plugins)
|
||||
}
|
||||
|
||||
/// Tells the windows about updates once per set of versions: a later
|
||||
/// refresh offering the same ones says nothing.
|
||||
fn announce_updates(&self, plugins: &[MarketPlugin]) {
|
||||
let updates: Vec<PluginUpdate> = plugins
|
||||
.iter()
|
||||
.filter(|p| p.is_update())
|
||||
.map(|p| PluginUpdate {
|
||||
id: p.entry.id.clone(),
|
||||
name: p.entry.name.clone(),
|
||||
version: p.entry.version.clone(),
|
||||
})
|
||||
.collect();
|
||||
let key: Vec<String> = updates.iter().map(|u| format!("{}@{}", u.id, u.version)).collect();
|
||||
if std::mem::replace(&mut *self.announced_updates.lock(), key.clone()) != key && !updates.is_empty() {
|
||||
self.emit(None, PluginEvent::UpdatesAvailable { updates });
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the package in `src` would replace the installed `id`'s copy
|
||||
/// with one signed by another key than that copy, without that key's
|
||||
/// endorsement: the user confirms the change of signer.
|
||||
pub(super) fn signer_changed(&self, id: &str, src: &Path) -> bool {
|
||||
let Some(current) = install::active_dir(&self.plugins_dir.join(id)) else { return false };
|
||||
let market = self.market.read();
|
||||
!package::signer_continues(market.as_ref(), read_signature(¤t).as_ref(), read_signature(src).as_ref())
|
||||
}
|
||||
|
||||
/// The plugins of the last index read.
|
||||
@@ -119,16 +225,14 @@ impl PluginHost {
|
||||
let Some(index) = self.market.read().clone() else { return Vec::new() };
|
||||
let plugins = self.plugins.read();
|
||||
index
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|e| index.revocation(&e.id, &e.version).is_none())
|
||||
.cloned()
|
||||
.map(|entry| {
|
||||
.offered()
|
||||
.into_iter()
|
||||
.map(|(entry, compatible)| {
|
||||
let installed = plugins
|
||||
.get(&entry.id)
|
||||
.filter(|p| p.source == PluginSource::Installed)
|
||||
.map(|p| p.manifest().version.clone());
|
||||
MarketPlugin { entry, installed }
|
||||
MarketPlugin { entry: entry.clone(), installed, compatible }
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -139,12 +243,18 @@ impl PluginHost {
|
||||
pub fn download_market_plugin(&self, id: &str) -> IdeResult<PathBuf> {
|
||||
let url = self.market_url().ok_or_else(|| IdeError::unavailable("no plugin marketplace is configured"))?;
|
||||
let index = self.market.read().clone().ok_or_else(|| IdeError::unavailable("refresh the marketplace first"))?;
|
||||
let entry = index
|
||||
.plugins
|
||||
.iter()
|
||||
.find(|e| e.id == id)
|
||||
.cloned()
|
||||
let (entry, compatible) = index
|
||||
.offered()
|
||||
.into_iter()
|
||||
.find(|(e, _)| e.id == id)
|
||||
.map(|(e, ok)| (e.clone(), ok))
|
||||
.ok_or_else(|| IdeError::not_found(format!("{id} is not in the marketplace")))?;
|
||||
if !compatible {
|
||||
return Err(IdeError::unavailable(format!(
|
||||
"{} {} needs plugin API {}: update OpenIDEA",
|
||||
entry.name, entry.version, entry.engine
|
||||
)));
|
||||
}
|
||||
let bytes = read_url(&resolve(&url, &entry.download), MAX_PACKAGE).map_err(IdeError::unavailable)?;
|
||||
let downloads = self.plugins_dir.join(DOWNLOADS);
|
||||
std::fs::create_dir_all(&downloads)?;
|
||||
@@ -182,6 +292,45 @@ impl PluginHost {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Writes a package the UI generated (an imported IntelliJ theme) into a
|
||||
/// folder next to the downloads, named after `name` (its plugin id); returns the folder.
|
||||
/// The install page validates it like any other package.
|
||||
pub fn stage_package(&self, name: &str, files: &[PackageFile]) -> IdeResult<PathBuf> {
|
||||
ide_extension::validate_item_id(name, "package name").map_err(IdeError::invalid)?;
|
||||
if !files.iter().any(|f| f.path == MANIFEST_FILE) {
|
||||
return Err(IdeError::invalid(format!("a package needs a {MANIFEST_FILE}")));
|
||||
}
|
||||
let total: usize = files.iter().map(|f| f.text.len()).sum();
|
||||
if files.len() > MAX_STAGED_FILES || total > MAX_STAGED_BYTES {
|
||||
return Err(IdeError::invalid("the package is too large"));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for f in files {
|
||||
let valid = !f.path.is_empty()
|
||||
&& f.path.len() <= 255
|
||||
&& f.path.split('/').all(|part| {
|
||||
!matches!(part, "" | "." | "..") && !part.contains(['\\', ':', '\0']) && !part.starts_with('.')
|
||||
});
|
||||
if !valid {
|
||||
return Err(IdeError::invalid(format!("invalid package path {:?}", f.path)));
|
||||
}
|
||||
if !seen.insert(f.path.to_ascii_lowercase()) {
|
||||
return Err(IdeError::invalid(format!("{} is in the package twice", f.path)));
|
||||
}
|
||||
}
|
||||
// One folder per package, replaced by the next import of it: they don't pile up.
|
||||
let dir = self.plugins_dir.join(DOWNLOADS).join(format!("{name}-generated"));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
for f in files {
|
||||
let path = dir.join(&f.path);
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(&path, &f.text)?;
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Files of the package in `dir` (what its declarations reference, and
|
||||
/// the component): what a package file holds besides the manifest.
|
||||
pub fn package_files_of(&self, dir: &Path) -> IdeResult<Vec<PathBuf>> {
|
||||
|
||||
@@ -16,8 +16,12 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
mod points;
|
||||
mod providers;
|
||||
@@ -32,7 +36,10 @@ pub mod web;
|
||||
|
||||
pub use api::{FileSecretStore, HostWorkspace, PluginWorkspace, SecretStore};
|
||||
pub use icons::{map_asset as icon_theme_map_asset, sanitize_svg, svg_prefix as icon_theme_svg_prefix};
|
||||
pub use market::{INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX};
|
||||
pub use market::{
|
||||
INDEX_FILE as MARKET_INDEX_FILE, MarketPlugin, OFFICIAL_INDEX as OFFICIAL_MARKET_INDEX, OFFICIAL_PUBLISHER,
|
||||
read_url as read_market_url, resolve as resolve_market_href,
|
||||
};
|
||||
pub use points::{COMMANDS, CommandDecl, register_builtin_points};
|
||||
pub(crate) use providers::editor_providers as editor_providers_for;
|
||||
pub use providers::{ProviderKind, set_editor_providers};
|
||||
@@ -238,12 +245,16 @@ pub struct Consent {
|
||||
/// Optional capabilities the user accepted (`net:crates.io`, …). Required
|
||||
/// ones are implied by consenting at all.
|
||||
pub optional: Vec<String>,
|
||||
/// The user accepted a package signed by another key than the installed
|
||||
/// copy ([`InstallPreview::signer_changed`]).
|
||||
#[serde(default)]
|
||||
pub signer_change: bool,
|
||||
}
|
||||
|
||||
impl Consent {
|
||||
/// Accept everything the plugin asks for (tests, trusted official plugins).
|
||||
pub fn all() -> Self {
|
||||
Self { optional: vec!["*".into()] }
|
||||
Self { optional: vec!["*".into()], signer_change: true }
|
||||
}
|
||||
|
||||
fn accepts(&self, cap: &Capability) -> bool {
|
||||
@@ -270,6 +281,14 @@ pub struct InstallPreview {
|
||||
pub unsigned: bool,
|
||||
/// The verified publisher.
|
||||
pub publisher: Option<String>,
|
||||
/// Replaces an installed copy signed with another key, which didn't
|
||||
/// endorse this package's (or this one is unsigned): the user confirms it
|
||||
/// ([`Consent::signer_change`]).
|
||||
pub signer_changed: bool,
|
||||
/// Something to confirm: a first install, an unsigned package, another
|
||||
/// signer, or capabilities the installed version wasn't granted or
|
||||
/// refused. Without, an update installs with the grants it has.
|
||||
pub needs_consent: bool,
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
@@ -316,6 +335,10 @@ pub struct PluginHost {
|
||||
pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// The last marketplace index read: trust root for signatures, revocations.
|
||||
market: RwLock<Option<ide_extension::package::Index>>,
|
||||
/// The official publisher's keys the index is pinned to (none: as listed).
|
||||
official_keys: RwLock<Vec<String>>,
|
||||
/// `id@version` of the updates last announced.
|
||||
announced_updates: Mutex<Vec<String>>,
|
||||
/// The marketplace used while the user has set none (the app's official one).
|
||||
default_market_url: RwLock<Option<String>>,
|
||||
/// Updates not confirmed yet (the new version hasn't started once): what
|
||||
@@ -354,9 +377,12 @@ impl PluginHost {
|
||||
policy_pins.read().get(&(plugin.to_owned(), cap.to_string())).cloned()
|
||||
}));
|
||||
let shared = api::Shared::new(sink.clone(), plugins_dir.join(".data"), pins.clone(), ui.clone());
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir));
|
||||
let official_keys = market::built_in_official_keys();
|
||||
let market = RwLock::new(Self::load_market_index(&plugins_dir, &official_keys));
|
||||
let host = Self {
|
||||
market,
|
||||
official_keys: RwLock::new(official_keys),
|
||||
announced_updates: Mutex::default(),
|
||||
default_market_url: RwLock::new(None),
|
||||
updates: Mutex::default(),
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
@@ -699,14 +725,27 @@ impl PluginHost {
|
||||
}
|
||||
let publisher = self.verified_publisher(src);
|
||||
let m = &pkg.manifest;
|
||||
let replaces = self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone());
|
||||
// An update shows what the installed version was granted.
|
||||
let grant = replaces
|
||||
.as_ref()
|
||||
.and_then(|_| self.ledger.lock().get(&Subject::Installed(m.id.clone())).cloned())
|
||||
.filter(|g| g.confirmed);
|
||||
let granted = |c: &Capability| grant.as_ref().is_some_and(|g| g.has(c));
|
||||
let mut capabilities: Vec<CapabilityInfo> =
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), false)).collect();
|
||||
m.capabilities.all().map(|c| CapabilityInfo::new(c, m.capabilities.is_optional(c), granted(c))).collect();
|
||||
for extra in &set.capabilities {
|
||||
if !m.declares(extra) {
|
||||
capabilities.push(CapabilityInfo::new(extra, true, false));
|
||||
capabilities.push(CapabilityInfo::new(extra, true, granted(extra)));
|
||||
}
|
||||
}
|
||||
capabilities.sort_by(|a, b| b.risk.cmp(&a.risk).then(a.id.cmp(&b.id)));
|
||||
let signer_changed = self.signer_changed(&m.id, src);
|
||||
let answered = |c: &CapabilityInfo| {
|
||||
c.granted || (c.optional && grant.as_ref().is_some_and(|g| g.declined.contains(&c.id)))
|
||||
};
|
||||
let needs_consent =
|
||||
grant.is_none() || publisher.is_none() || signer_changed || !capabilities.iter().all(answered);
|
||||
Ok(InstallPreview {
|
||||
id: m.id.clone(),
|
||||
name: m.name.clone(),
|
||||
@@ -717,9 +756,11 @@ impl PluginHost {
|
||||
capabilities,
|
||||
contributions: counts(m),
|
||||
warnings: set.warnings.clone(),
|
||||
replaces: self.plugins.read().get(&m.id).map(|p| p.manifest().version.clone()),
|
||||
replaces,
|
||||
unsigned: publisher.is_none(),
|
||||
publisher,
|
||||
signer_changed,
|
||||
needs_consent,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -742,6 +783,11 @@ impl PluginHost {
|
||||
if let Some(problem) = self.signature_problem(src) {
|
||||
return Err(IdeError::invalid(format!("refusing {id}: {problem}")));
|
||||
}
|
||||
if !consent.signer_change && self.signer_changed(&id, src) {
|
||||
return Err(IdeError::invalid(format!(
|
||||
"refusing {id}: it is signed by another key than the installed version; confirm the change of signer"
|
||||
)));
|
||||
}
|
||||
let version = pkg.manifest.version.clone();
|
||||
if let Some(r) = self.market.read().as_ref().and_then(|i| i.revocation(&id, &version)) {
|
||||
return Err(IdeError::invalid(format!("{id} {version} was revoked: {}", r.reason)));
|
||||
@@ -2207,6 +2253,7 @@ fn placeholder_manifest(dir: &Path) -> Manifest {
|
||||
homepage: None,
|
||||
license: None,
|
||||
change_notes: None,
|
||||
declared_categories: Vec::new(),
|
||||
engine: ide_extension::ENGINE_VERSION.into(),
|
||||
legacy: false,
|
||||
wasm: None,
|
||||
|
||||
@@ -272,7 +272,8 @@ impl RemotePlugins {
|
||||
report.failed.push(format!("{}: invalid plugin id", pkg.id));
|
||||
continue;
|
||||
}
|
||||
let consent = Consent { optional: pkg.granted.clone() };
|
||||
// The desktop asked the user, change of signer included.
|
||||
let consent = Consent { optional: pkg.granted.clone(), signer_change: true };
|
||||
if self.current_digest(&pkg.id).as_deref() == Some(pkg.digest.as_str()) {
|
||||
if let Err(e) = self.follow_grants(&pkg, &consent) {
|
||||
report.failed.push(format!("{}: {e}", pkg.id));
|
||||
|
||||
@@ -134,7 +134,7 @@ impl ExtensionPoint for ThemesPoint {
|
||||
|
||||
/// Blanks out `//` and `/* */` comments outside strings, keeping newlines so
|
||||
/// parse errors still point at the right line.
|
||||
fn strip_json_comments(src: &str) -> String {
|
||||
pub(super) fn strip_json_comments(src: &str) -> String {
|
||||
let mut out = String::with_capacity(src.len());
|
||||
let mut chars = src.chars().peekable();
|
||||
let (mut in_str, mut escaped) = (false, false);
|
||||
|
||||
@@ -138,12 +138,24 @@ pub struct PluginsSnapshot {
|
||||
pub ui: Vec<UiContribution>,
|
||||
}
|
||||
|
||||
/// A newer version of an installed plugin in the marketplace.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginUpdate {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// Backend → UI notifications (Tauri event `ide://plugin`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum PluginEvent {
|
||||
/// Plugins, their status or their contributions changed: re-fetch `plugin_list`.
|
||||
CommandsChanged,
|
||||
/// The marketplace offers newer versions of installed plugins (announced
|
||||
/// once per set of versions).
|
||||
UpdatesAvailable { updates: Vec<PluginUpdate> },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Notification { plugin_id: String, level: NotificationLevel, title: String, body: String },
|
||||
/// A plugin's status bar item was set (`item`) or removed.
|
||||
@@ -212,6 +224,14 @@ impl PluginEditor {
|
||||
}
|
||||
}
|
||||
|
||||
/// A text file of a package the UI generated, at its `/`-separated path in the package.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PackageFile {
|
||||
pub path: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
/// A plugin's status bar item.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -305,6 +305,7 @@ impl Host {
|
||||
index.update(&changed, &batch.removed);
|
||||
kotlin_bridge.files_changed(changed.iter().chain(&batch.removed));
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
lsp.watched_files_changed(&batch.created, &batch.modified, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
|
||||
@@ -257,6 +257,8 @@ impl Dispatcher {
|
||||
&c.arg::<Option<Vec<String>>>("removedDetectedIds")?.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.add("run_without_derived", |c| async move { ops::run::without_derived(&c.host, &c.window, c.arg("configs")?) })
|
||||
.add("run_apply_settings", |c| async move { ops::run::apply_settings(&c.host, &c.window, c.arg("configs")?) })
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
|
||||
@@ -177,6 +177,10 @@ pub(crate) fn read_login_env(shell: &str, timeout: std::time::Duration) -> Optio
|
||||
}
|
||||
}
|
||||
}
|
||||
// Nor what the last read found that this process lacks: children get it, the shell must not.
|
||||
for (k, _) in ide_core::process::shell_env() {
|
||||
cmd.env_remove(k);
|
||||
}
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
let mut child = cmd
|
||||
.args(["-ilc", &format!("printf '{MARK}'; /usr/bin/env -0; printf '{MARK}'")])
|
||||
@@ -257,6 +261,14 @@ mod tests {
|
||||
std::fs::write(&rc, "export OPENIDEA_TEST_RC='two words'\n").unwrap();
|
||||
let second = read_login_env(&shell, Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(get(&second, "OPENIDEA_TEST_RC").as_deref(), Some("two words"));
|
||||
|
||||
// What a read found goes to children, not into the next read: an rc file that no
|
||||
// longer exports a variable drops it.
|
||||
ide_core::process::set_shell_env(second);
|
||||
std::fs::write(&rc, "export OPENIDEA_TEST_SEEN=\"${OPENIDEA_TEST_RC:-unset}\"\n").unwrap();
|
||||
let third = read_login_env(&shell, Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(get(&third, "OPENIDEA_TEST_RC"), None);
|
||||
assert_eq!(get(&third, "OPENIDEA_TEST_SEEN").as_deref(), Some("unset"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -131,25 +131,44 @@ fn installs_from_before_versioning_keep_working_and_move_on_update() {
|
||||
|
||||
/// `hello` as `acme.hello` at `version`, packed and signed with `key`.
|
||||
fn signed_hello(dir: &Path, version: &str, key: &PublisherKey, host: &PluginHost) -> (PathBuf, IndexEntry) {
|
||||
let src = variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{version}"), |t| {
|
||||
t.replace("id = \"hello\"", "id = \"acme.hello\"")
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\""))
|
||||
});
|
||||
signed(dir, "acme", version, key, host, |t| t.to_owned())
|
||||
}
|
||||
|
||||
/// `hello` as `<publisher>.hello` at `version`, its manifest changed by
|
||||
/// `edit`, packed and signed with `key`.
|
||||
fn signed(
|
||||
dir: &Path,
|
||||
publisher: &str,
|
||||
version: &str,
|
||||
key: &PublisherKey,
|
||||
host: &PluginHost,
|
||||
edit: impl Fn(&str) -> String,
|
||||
) -> (PathBuf, IndexEntry) {
|
||||
let id = format!("{publisher}.hello");
|
||||
let src =
|
||||
variant(&repo().join("crates/ide-plugin/tests/fixtures/hello"), dir, &format!("src-{id}-{version}"), |t| {
|
||||
edit(
|
||||
&t.replace("id = \"hello\"", &format!("id = \"{id}\""))
|
||||
.replace("version = \"0.3.0\"", &format!("version = \"{version}\"")),
|
||||
)
|
||||
});
|
||||
let files = host.package_files_of(&src).unwrap();
|
||||
let digest = package::package_digest(&src, &files).unwrap();
|
||||
let sig = key.sign("acme", "acme.hello", version, &digest);
|
||||
let out = dir.join(format!("acme.hello-{version}.oiplugin"));
|
||||
let sig = key.sign(publisher, &id, version, &digest);
|
||||
let out = dir.join(format!("{id}-{version}.oiplugin"));
|
||||
package::pack(&src, &files, &out, Some(&sig)).unwrap();
|
||||
let entry = IndexEntry {
|
||||
id: "acme.hello".into(),
|
||||
id: id.clone(),
|
||||
name: "Hello".into(),
|
||||
version: version.into(),
|
||||
description: String::new(),
|
||||
authors: vec![],
|
||||
publisher: "acme".into(),
|
||||
publisher: publisher.into(),
|
||||
download: out.file_name().unwrap().to_string_lossy().into_owned(),
|
||||
digest,
|
||||
capabilities: vec![],
|
||||
engine: "^1.0".into(),
|
||||
categories: vec!["tools".into()],
|
||||
};
|
||||
(out, entry)
|
||||
}
|
||||
@@ -166,10 +185,11 @@ fn write_index(
|
||||
publishers: vec![Publisher {
|
||||
id: "acme".into(),
|
||||
name: "ACME".into(),
|
||||
keys: vec![KeyEntry { key: key.public_base64(), revoked: key_revoked }],
|
||||
keys: vec![KeyEntry { revoked: key_revoked, ..KeyEntry::new(key.public_base64()) }],
|
||||
}],
|
||||
plugins,
|
||||
revoked,
|
||||
moved: None,
|
||||
};
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
@@ -260,3 +280,210 @@ fn package_files_install_from_disk_unsigned() {
|
||||
let info = f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
assert_eq!((info.status, info.publisher), (PluginStatus::Inactive, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_marketplace_offers_the_newest_version_this_ide_can_load() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, old) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
let (_, new) = signed_hello(&market, "2.0.0", &key, &f.host);
|
||||
let next_major = ide_extension::ENGINE_VERSION.split('.').next().unwrap().parse::<u64>().unwrap() + 1;
|
||||
let new = IndexEntry { engine: format!("^{next_major}.0"), ..new };
|
||||
f.host.set_market_url(Some(write_index(&market, &key, vec![old.clone(), new.clone()], vec![], false)));
|
||||
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
assert_eq!((listed.len(), listed[0].entry.version.as_str(), listed[0].compatible), (1, "1.0.0", true));
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0");
|
||||
|
||||
// Only a version for a newer IDE: shown as such, not downloaded.
|
||||
write_index(&market, &key, vec![new], vec![], false);
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
assert_eq!((listed[0].entry.version.as_str(), listed[0].compatible), ("2.0.0", false));
|
||||
let err = f.host.download_market_plugin("acme.hello").unwrap_err();
|
||||
assert!(err.message.contains("update OpenIDEA"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_moved_marketplace_is_read_where_it_moved_to() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
write_index(&market, &key, vec![entry], vec![], false);
|
||||
// The old address points at the new one; downloads resolve against where the index is.
|
||||
let old = f.work.join("old");
|
||||
std::fs::create_dir_all(&old).unwrap();
|
||||
std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "../market/index.json"}"#).unwrap();
|
||||
f.host.set_market_url(Some(old.join("index.json").to_string_lossy().into_owned()));
|
||||
|
||||
let listed = f.host.refresh_market().unwrap();
|
||||
assert_eq!(listed[0].entry.id, "acme.hello");
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().version, "1.0.0");
|
||||
|
||||
// A loop gives up.
|
||||
std::fs::write(old.join("index.json"), r#"{"format": 1, "moved": "index.json"}"#).unwrap();
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("moved more than"), "{}", err.message);
|
||||
}
|
||||
|
||||
/// `dir/index.json` listing `publishers` and `plugins`; its path.
|
||||
fn index_of(dir: &Path, publishers: Vec<Publisher>, plugins: Vec<IndexEntry>) -> String {
|
||||
let index = Index { format: package::INDEX_FORMAT, publishers, plugins, ..Index::default() };
|
||||
let path = dir.join("index.json");
|
||||
std::fs::write(&path, serde_json::to_string(&index).unwrap()).unwrap();
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
fn publisher(id: &str, keys: Vec<KeyEntry>) -> Publisher {
|
||||
Publisher { id: id.into(), name: id.into(), keys }
|
||||
}
|
||||
|
||||
fn updates_announced(events: &Events) -> Vec<Vec<String>> {
|
||||
events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
PluginEvent::UpdatesAvailable { updates } => {
|
||||
Some(updates.iter().map(|u| format!("{} {}", u.id, u.version)).collect())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// What the install page sends for an update it doesn't need to show: the grants the installed version has.
|
||||
fn as_granted(preview: &InstallPreview) -> Consent {
|
||||
let optional = preview.capabilities.iter().filter(|c| c.optional && c.granted).map(|c| c.id.clone()).collect();
|
||||
Consent { optional, signer_change: false }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn updates_keep_their_signer_and_need_no_consent_when_nothing_is_new() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (k1, k2, k3) =
|
||||
(PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([2; 32]), PublisherKey::from_seed([3; 32]));
|
||||
let same = |t: &str| t.to_owned();
|
||||
let (_, v1) = signed(&market, "acme", "1.0.0", &k1, &f.host, same);
|
||||
f.host.set_market_url(Some(index_of(
|
||||
&market,
|
||||
vec![publisher("acme", vec![KeyEntry::new(k1.public_base64())])],
|
||||
vec![v1.clone()],
|
||||
)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let first = f.host.preview_install(&dir).unwrap();
|
||||
assert!(first.needs_consent && !first.signer_changed, "a first install is confirmed");
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
assert!(updates_announced(&f.events).is_empty());
|
||||
|
||||
// 1.1.0, same key and needs: announced once, installed with the grants it has.
|
||||
let (_, v11) = signed(&market, "acme", "1.1.0", &k1, &f.host, same);
|
||||
let keys1 = vec![KeyEntry::new(k1.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v1.clone(), v11.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
f.host.refresh_market().unwrap();
|
||||
assert_eq!(updates_announced(&f.events), [["acme.hello 1.1.0"]], "once per set of versions");
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!(preview.replaces.as_deref(), Some("1.0.0"));
|
||||
assert!(!preview.needs_consent && !preview.signer_changed);
|
||||
assert!(preview.capabilities.iter().all(|c| c.granted), "shows what the installed version was granted");
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert!(f.host.market_plugins().iter().all(|p| !p.is_update()));
|
||||
|
||||
// 1.2.0 asks for something new: the user is asked.
|
||||
let (_, v12) = signed(&market, "acme", "1.2.0", &k1, &f.host, |t| {
|
||||
t.replace(
|
||||
"required = [\"editor.read\", \"editor.write\"]",
|
||||
"required = [\"editor.read\", \"editor.write\"]\noptional = [\"net:example.com\"]",
|
||||
)
|
||||
});
|
||||
index_of(&market, vec![publisher("acme", keys1.clone())], vec![v12.clone()]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.needs_consent && !preview.signer_changed);
|
||||
f.host.install_from_dir(&dir, &Consent::default()).unwrap();
|
||||
|
||||
// 1.3.0 signed with a key the installed one didn't endorse: another signer, the user's call.
|
||||
let (_, v13) = signed(&market, "acme", "1.3.0", &k2, &f.host, same);
|
||||
let keys12 = vec![KeyEntry::new(k1.public_base64()), KeyEntry::new(k2.public_base64())];
|
||||
index_of(&market, vec![publisher("acme", keys12)], vec![v13]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(preview.signer_changed && preview.needs_consent);
|
||||
let err = f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap_err();
|
||||
assert!(err.message.contains("another key"), "{}", err.message);
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.2.0", "nothing changed");
|
||||
f.host.install_from_dir(&dir, &Consent { signer_change: true, ..as_granted(&preview) }).unwrap();
|
||||
|
||||
// 1.4.0 signed with k3, which k2 endorsed: a rotation the installed signer vouched for.
|
||||
let (_, v14) = signed(&market, "acme", "1.4.0", &k3, &f.host, same);
|
||||
let keys = vec![
|
||||
KeyEntry { endorsement: Some(k2.endorse("acme", &k3.public_base64())), ..KeyEntry::new(k3.public_base64()) },
|
||||
KeyEntry::new(k2.public_base64()),
|
||||
];
|
||||
index_of(&market, vec![publisher("acme", keys)], vec![v14]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert!(!preview.signer_changed && !preview.needs_consent);
|
||||
f.host.install_from_dir(&dir, &as_granted(&preview)).unwrap();
|
||||
assert_eq!(f.host.info_of("acme.hello").unwrap().version, "1.4.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_index_in_a_newer_format_still_revokes() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let key = PublisherKey::from_seed([3; 32]);
|
||||
let (_, entry) = signed_hello(&market, "1.0.0", &key, &f.host);
|
||||
f.host.set_market_url(Some(write_index(&market, &key, vec![entry], vec![], false)));
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("acme.hello").unwrap();
|
||||
f.host.install_from_dir(&dir, &Consent::all()).unwrap();
|
||||
|
||||
let newer = format!(
|
||||
r#"{{"format": {}, "plugins": "a shape this IDE can't read",
|
||||
"revoked": [{{"id": "acme.hello", "reason": "steals tokens"}}]}}"#,
|
||||
package::INDEX_FORMAT + 1
|
||||
);
|
||||
std::fs::write(market.join("index.json"), newer).unwrap();
|
||||
let err = f.host.refresh_market().unwrap_err();
|
||||
assert!(err.message.contains("newer OpenIDEA"), "{}", err.message);
|
||||
let info = f.host.info_of("acme.hello").unwrap();
|
||||
assert_eq!((info.status, info.error.as_deref()), (PluginStatus::Failed, Some("revoked: steals tokens")));
|
||||
assert!(f.host.market_plugins().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_plugins_need_the_key_this_ide_was_built_with() {
|
||||
let f = fixture();
|
||||
let market = f.work.join("market");
|
||||
std::fs::create_dir_all(&market).unwrap();
|
||||
let (root, intruder) = (PublisherKey::from_seed([1; 32]), PublisherKey::from_seed([6; 32]));
|
||||
f.host.set_official_keys(vec![root.public_base64()]);
|
||||
// The index lists the intruder's key for the official publisher.
|
||||
let (_, forged) = signed(&market, OFFICIAL_PUBLISHER, "1.0.0", &intruder, &f.host, |t| t.to_owned());
|
||||
let keys = vec![KeyEntry::new(intruder.public_base64())];
|
||||
f.host.set_market_url(Some(index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys.clone())], vec![forged])));
|
||||
f.host.refresh_market().unwrap();
|
||||
let err = f.host.download_market_plugin("openidea.hello").unwrap_err();
|
||||
assert!(err.message.contains("trusted publisher"), "{}", err.message);
|
||||
|
||||
// Signed with the built-in key it installs, whatever keys the index lists.
|
||||
let (_, genuine) = signed(&market, OFFICIAL_PUBLISHER, "1.0.1", &root, &f.host, |t| t.to_owned());
|
||||
index_of(&market, vec![publisher(OFFICIAL_PUBLISHER, keys)], vec![genuine]);
|
||||
f.host.refresh_market().unwrap();
|
||||
let dir = f.host.download_market_plugin("openidea.hello").unwrap();
|
||||
assert_eq!(f.host.preview_install(&dir).unwrap().publisher.as_deref(), Some(OFFICIAL_PUBLISHER));
|
||||
}
|
||||
@@ -399,19 +399,24 @@ fn crashes_restart_with_backoff_until_the_circuit_opens() {
|
||||
|
||||
// Three in a row: stopped for the session, with growing waits between.
|
||||
f.take_events();
|
||||
// How long each crash kept the next call waiting, without the restart itself, whose
|
||||
// instantiation time varies a lot on CI.
|
||||
let mut waits = Vec::new();
|
||||
for _ in 0..3 {
|
||||
let started = Instant::now();
|
||||
let err = loop {
|
||||
let attempt = Instant::now();
|
||||
match f.run("misbehave", "panic", None) {
|
||||
Err(e) if e.contains("restarts in") => std::thread::sleep(Duration::from_millis(20)),
|
||||
other => break other.unwrap_err(),
|
||||
other => {
|
||||
waits.push(attempt - started);
|
||||
break other.unwrap_err();
|
||||
}
|
||||
}
|
||||
};
|
||||
assert!(err.contains("crashed"), "{err}");
|
||||
waits.push(started.elapsed());
|
||||
}
|
||||
assert!(waits[2] >= backoff * 2 && waits[2] > waits[1], "{waits:?}");
|
||||
assert!(waits[1] >= backoff / 2 && waits[2] > waits[1] + backoff / 2, "the wait doubles: {waits:?}");
|
||||
let info = f.host.info_of("misbehave").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Failed);
|
||||
assert!(info.error.as_deref().is_some_and(|e| e.contains("3 times in a row")), "{:?}", info.error);
|
||||
@@ -1064,7 +1069,16 @@ fn plugin_syntax_for_languages_without_a_grammar() {
|
||||
let sol = LanguageId::new("solidity");
|
||||
let text = "// c\ncontract A {\n uint256 public x;\n function f() public {}\n}\n";
|
||||
assert!(provider.highlights(&LanguageId::new("rust"), text).is_none(), "only its own languages");
|
||||
let tokens = provider.highlights(&sol, text).unwrap();
|
||||
// The first call of a new instance can run past the edit-path budget on a
|
||||
// slow machine; its answer is dropped and a later call asks again.
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let tokens = loop {
|
||||
match provider.highlights(&sol, text) {
|
||||
Some(tokens) => break tokens,
|
||||
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)),
|
||||
None => panic!("no highlights"),
|
||||
}
|
||||
};
|
||||
let kind_at =
|
||||
|s: &str| tokens.iter().find(|t| &text[t.start as usize..t.end as usize] == s).map(|t| t.kind.as_str());
|
||||
assert_eq!(kind_at("contract"), Some("keyword"));
|
||||
@@ -2300,6 +2314,57 @@ fn a_zipped_plugin_folder_installs_like_the_folder() {
|
||||
assert!(err.contains("is not an OpenIDEA plugin"), "{err}");
|
||||
}
|
||||
|
||||
/// What the UI writes for an imported IntelliJ theme goes through the install page.
|
||||
#[test]
|
||||
fn a_generated_package_is_staged_for_the_install_page() {
|
||||
let f = fixture(Limits::default());
|
||||
let file = |path: &str, text: &str| PackageFile { path: path.into(), text: text.into() };
|
||||
let manifest = r#"id = "intellij.deep-sea"
|
||||
name = "Deep Sea"
|
||||
version = "2.3.0"
|
||||
engines.openidea = "^1.0"
|
||||
|
||||
[[contributes.themes]]
|
||||
path = "themes/deep-sea.theme.json"
|
||||
editor-scheme = "themes/deep-sea.scheme.json"
|
||||
"#;
|
||||
let files = [
|
||||
file("plugin.toml", manifest),
|
||||
file(
|
||||
"themes/deep-sea.theme.json",
|
||||
r##"{"id":"deep-sea","name":"Deep Sea","kind":"dark","tokens":{"--bg-app":"#101820"}}"##,
|
||||
),
|
||||
file("themes/deep-sea.scheme.json", r##"{"name":"Deep Sea","tokens":{"--syn-keyword":"#cc7832"}}"##),
|
||||
];
|
||||
let dir = f.host.stage_package("intellij.deep-sea", &files).unwrap();
|
||||
let preview = f.host.preview_install(&dir).unwrap();
|
||||
assert_eq!((preview.id.as_str(), preview.has_code, preview.unsigned), ("intellij.deep-sea", false, true));
|
||||
assert!(preview.capabilities.is_empty());
|
||||
f.install(&dir);
|
||||
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
|
||||
assert_eq!(active.theme.tokens["--bg-app"], "#101820");
|
||||
assert_eq!(active.theme.tokens["--syn-keyword"], "#cc7832");
|
||||
|
||||
// Staging the same package again starts from an empty folder; importing it again replaces it.
|
||||
assert_eq!(f.host.stage_package("intellij.deep-sea", &files[..1]).unwrap(), dir);
|
||||
assert!(!dir.join("themes").exists());
|
||||
let mut changed = files.clone();
|
||||
changed[2] = file("themes/deep-sea.scheme.json", r##"{"tokens":{"--syn-keyword":"#ff8800"}}"##);
|
||||
f.install(&f.host.stage_package("intellij.deep-sea", &changed).unwrap());
|
||||
let active = f.host.set_theme(Some(("intellij.deep-sea", "deep-sea"))).unwrap().unwrap();
|
||||
assert_eq!(active.theme.tokens["--syn-keyword"], "#ff8800");
|
||||
|
||||
for bad in ["../x", "/abs", "a//b", r"a\b", "C:x", ".hidden", "a/./b", ""] {
|
||||
let err = f.host.stage_package("p", &[file("plugin.toml", manifest), file(bad, "x")]).unwrap_err().message;
|
||||
assert!(err.contains("invalid package path"), "{bad:?}: {err}");
|
||||
}
|
||||
let err = f.host.stage_package("p", &[file("x.json", "{}")]).unwrap_err().message;
|
||||
assert!(err.contains("needs a plugin.toml"), "{err}");
|
||||
let err = f.host.stage_package("p", &[file("plugin.toml", ""), file("PLUGIN.toml", "")]).unwrap_err().message;
|
||||
assert!(err.contains("twice"), "{err}");
|
||||
assert!(f.host.stage_package("../p", &files).is_err());
|
||||
}
|
||||
|
||||
/// Secrets live outside the plugin's directory (the OS keychain on the
|
||||
/// desktop): uninstalling the plugin deletes the ones it declares.
|
||||
#[test]
|
||||
|
||||
@@ -218,6 +218,13 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
|
||||
until("git repository", || project.join(".git").is_dir()).await;
|
||||
|
||||
// A Docker run configuration's environment comes from the host its command runs on.
|
||||
let env =
|
||||
invoke(&peer, "docker_run_env", json!({ "endpoint": { "kind": "socket", "path": "/run/user/1/docker.sock" } }))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(env["DOCKER_HOST"], "unix:///run/user/1/docker.sock");
|
||||
|
||||
// Real project-host ignore writes travel through the remote command dispatcher.
|
||||
assert!(Dispatcher::standard().commands().contains(&"vcs_add_to_ignore"));
|
||||
let ignore_root = dunce::canonicalize(&project).unwrap();
|
||||
|
||||
@@ -81,6 +81,46 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
assert_eq!(saved.command, gradle.command);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn maven_defaults_are_resolved_at_launch_and_explicit_choices_survive_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("pom.xml"), "<project/>").unwrap();
|
||||
let home = root.join("maven");
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
let launcher = home.join("bin").join(if cfg!(windows) { "mvn.cmd" } else { "mvn" });
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
for (key, value) in [
|
||||
("languages.java.maven.home", home.to_string_lossy().into_owned()),
|
||||
("languages.java.maven.userSettings", "settings.xml".into()),
|
||||
] {
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
"main",
|
||||
ide_workspace::settings::SettingsScope::Project,
|
||||
key,
|
||||
&serde_json::to_string(&value).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let mut config = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == "maven:test").unwrap();
|
||||
assert_eq!(config.command, "mvn");
|
||||
assert_eq!(config.args, ["test"]);
|
||||
assert!(config.env.is_empty());
|
||||
config.command = launcher.to_string_lossy().into_owned();
|
||||
config.args = vec!["-s".into(), "own-settings.xml".into(), "test".into()];
|
||||
config.env.insert("JAVA_HOME".into(), "own-jdk".into());
|
||||
config.detected = false;
|
||||
ops::run::save_configs(&host, "main", &[config.clone()], &[]).unwrap();
|
||||
let saved = ops::run::configs(&host, "main").unwrap().into_iter().find(|c| c.id == config.id).unwrap();
|
||||
assert_eq!(saved.command, config.command);
|
||||
assert_eq!(saved.args, config.args);
|
||||
assert_eq!(saved.env, config.env);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -131,6 +131,16 @@ pub struct LanguageServerConfig {
|
||||
/// basedpyright): only used while none of those is installed.
|
||||
#[serde(default)]
|
||||
pub fallback: bool,
|
||||
/// What the server is used for (`completion`, `code-action`, `diagnostics`,
|
||||
/// … `FEATURES` in `ide-lsp/src/caps.rs`); empty means everything. The other
|
||||
/// capabilities it announces are ignored, so a refactoring-only server next
|
||||
/// to the language's own one adds no completion, hover or formatting.
|
||||
#[serde(default)]
|
||||
pub features: Vec<String>,
|
||||
/// Code action kinds kept from the server, matched like LSP's `only`
|
||||
/// (`refactor` keeps `refactor.extract`); empty keeps every kind.
|
||||
#[serde(default)]
|
||||
pub code_action_kinds: Vec<String>,
|
||||
/// Newest Java release the server runs on (kotlin-language-server: 21). A
|
||||
/// newer `JAVA_HOME` is replaced by the newest installed JDK that fits.
|
||||
#[serde(default)]
|
||||
|
||||
+258
-1
@@ -183,6 +183,8 @@ pub struct ServerCaps {
|
||||
/// `Some(include_text)` when the server wants `didSave`.
|
||||
pub save: Option<bool>,
|
||||
pub semantic_legend: Option<(Vec<String>, Vec<String>)>,
|
||||
/// Applied to what the server announces, now and in later registrations.
|
||||
pub restriction: Restriction,
|
||||
}
|
||||
|
||||
/// LSP request method → server capability key, for features and dynamic registration.
|
||||
@@ -215,6 +217,104 @@ pub fn capability_key(method: &str) -> Option<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Names for a server's `features` → the capability keys each one keeps.
|
||||
pub const FEATURES: &[(&str, &[&str])] = &[
|
||||
("completion", &["completionProvider"]),
|
||||
("inline-completion", &["inlineCompletionProvider"]),
|
||||
("hover", &["hoverProvider"]),
|
||||
("signature-help", &["signatureHelpProvider"]),
|
||||
("definition", &["definitionProvider"]),
|
||||
("declaration", &["declarationProvider"]),
|
||||
("type-definition", &["typeDefinitionProvider"]),
|
||||
("implementation", &["implementationProvider"]),
|
||||
("references", &["referencesProvider"]),
|
||||
("document-highlight", &["documentHighlightProvider"]),
|
||||
("document-symbol", &["documentSymbolProvider"]),
|
||||
("workspace-symbol", &["workspaceSymbolProvider"]),
|
||||
("code-action", &["codeActionProvider", "executeCommandProvider"]),
|
||||
("code-lens", &["codeLensProvider", "executeCommandProvider"]),
|
||||
("rename", &["renameProvider"]),
|
||||
(
|
||||
"formatting",
|
||||
&["documentFormattingProvider", "documentRangeFormattingProvider", "documentOnTypeFormattingProvider"],
|
||||
),
|
||||
("inlay-hint", &["inlayHintProvider"]),
|
||||
("semantic-tokens", &["semanticTokensProvider"]),
|
||||
("document-link", &["documentLinkProvider"]),
|
||||
("call-hierarchy", &["callHierarchyProvider"]),
|
||||
("type-hierarchy", &["typeHierarchyProvider"]),
|
||||
("folding-range", &["foldingRangeProvider"]),
|
||||
("selection-range", &["selectionRangeProvider"]),
|
||||
("diagnostics", &["diagnosticProvider"]),
|
||||
];
|
||||
|
||||
/// A server's `features` and `code-action-kinds`: the part of its capabilities
|
||||
/// the IDE uses.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Restriction {
|
||||
/// Allowed `*Provider` keys; `None` allows all.
|
||||
keys: Option<Vec<&'static str>>,
|
||||
/// Code action kind prefixes kept; empty keeps all.
|
||||
action_kinds: Vec<String>,
|
||||
}
|
||||
|
||||
impl Restriction {
|
||||
/// Unknown feature names are logged and ignored.
|
||||
pub fn new(server: &str, features: &[String], action_kinds: &[String]) -> Self {
|
||||
let keys = (!features.is_empty()).then(|| {
|
||||
let mut keys = Vec::new();
|
||||
for name in features {
|
||||
match FEATURES.iter().find(|(n, _)| n == name) {
|
||||
Some((_, k)) => keys.extend_from_slice(k),
|
||||
None => tracing::warn!("{server}: unknown feature `{name}` in `features`"),
|
||||
}
|
||||
}
|
||||
keys
|
||||
});
|
||||
Self { keys, action_kinds: action_kinds.to_vec() }
|
||||
}
|
||||
|
||||
fn allows(&self, key: &str) -> bool {
|
||||
self.keys.as_ref().is_none_or(|k| k.contains(&key))
|
||||
}
|
||||
|
||||
/// Whether the server's `publishDiagnostics` are shown.
|
||||
pub fn diagnostics(&self) -> bool {
|
||||
self.allows("diagnosticProvider")
|
||||
}
|
||||
|
||||
/// Whether a code action of `kind` is kept; one without a kind only when
|
||||
/// every kind is.
|
||||
pub fn keeps_action(&self, kind: Option<&str>) -> bool {
|
||||
self.action_kinds.is_empty() || kind.is_some_and(|k| self.action_kinds.iter().any(|p| kind_within(k, p)))
|
||||
}
|
||||
|
||||
/// The kinds the server can still return: those it lists that are kept,
|
||||
/// narrowed to the kept prefixes below them (`refactor` → `refactor.extract`).
|
||||
fn advertised_kinds(&self, listed: Option<Vec<String>>) -> Vec<String> {
|
||||
let Some(listed) = listed else { return self.action_kinds.clone() };
|
||||
let mut out = Vec::new();
|
||||
for k in &listed {
|
||||
let narrowed: Vec<&String> = if self.keeps_action(Some(k)) {
|
||||
vec![k]
|
||||
} else {
|
||||
self.action_kinds.iter().filter(|p| kind_within(p, k)).collect()
|
||||
};
|
||||
for k in narrowed {
|
||||
if !out.contains(k) {
|
||||
out.push(k.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// `kind` is `prefix` or below it (`refactor.extract` within `refactor`).
|
||||
fn kind_within(kind: &str, prefix: &str) -> bool {
|
||||
kind.strip_prefix(prefix).is_some_and(|rest| rest.is_empty() || rest.starts_with('.'))
|
||||
}
|
||||
|
||||
fn truthy(v: Option<&Value>) -> bool {
|
||||
!matches!(v, None | Some(Value::Null) | Some(Value::Bool(false)))
|
||||
}
|
||||
@@ -244,7 +344,34 @@ impl ServerCaps {
|
||||
};
|
||||
(strings("tokenTypes"), strings("tokenModifiers"))
|
||||
});
|
||||
Self { raw, encoding, open_close, sync, save, semantic_legend }
|
||||
Self { raw, encoding, open_close, sync, save, semantic_legend, restriction: Restriction::default() }
|
||||
}
|
||||
|
||||
/// Drops the capabilities outside the server's `features`, and code action
|
||||
/// kinds outside its `code-action-kinds`. Document sync, workspace options
|
||||
/// and `experimental` stay.
|
||||
pub fn restrict(&mut self, restriction: Restriction) {
|
||||
if let Some(o) = self.raw.as_object_mut() {
|
||||
o.retain(|key, _| !key.ends_with("Provider") || restriction.allows(key));
|
||||
}
|
||||
if self.has("codeActionProvider") && !restriction.action_kinds.is_empty() {
|
||||
let kinds = restriction.advertised_kinds(self.code_action_kinds());
|
||||
if kinds.is_empty() {
|
||||
// It lists only kinds that aren't kept: nothing to ask it for.
|
||||
if let Some(o) = self.raw.as_object_mut() {
|
||||
o.remove("codeActionProvider");
|
||||
}
|
||||
} else {
|
||||
if !self.raw["codeActionProvider"].is_object() {
|
||||
self.raw["codeActionProvider"] = json!({});
|
||||
}
|
||||
self.raw["codeActionProvider"]["codeActionKinds"] = json!(kinds);
|
||||
}
|
||||
}
|
||||
if !restriction.allows("semanticTokensProvider") {
|
||||
self.semantic_legend = None;
|
||||
}
|
||||
self.restriction = restriction;
|
||||
}
|
||||
|
||||
/// Whether the server statically advertises the capability under `key`.
|
||||
@@ -325,11 +452,18 @@ impl ServerCaps {
|
||||
self.raw["workspace"]["textDocumentContent"] = opts.clone();
|
||||
return;
|
||||
}
|
||||
if let Some(key) = capability_key(method)
|
||||
&& !self.restriction.allows(key)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if method == "textDocument/semanticTokens"
|
||||
&& let Some(opts) = options
|
||||
{
|
||||
self.raw["semanticTokensProvider"] = opts.clone();
|
||||
let restriction = std::mem::take(&mut self.restriction);
|
||||
*self = Self::parse(std::mem::take(&mut self.raw));
|
||||
self.restriction = restriction;
|
||||
return;
|
||||
}
|
||||
if method == "textDocument/didSave" {
|
||||
@@ -349,6 +483,11 @@ impl ServerCaps {
|
||||
&& !self.has(key)
|
||||
{
|
||||
self.raw[key] = options.cloned().filter(|o| o.is_object()).unwrap_or(Value::Bool(true));
|
||||
if key == "codeActionProvider" {
|
||||
// Registered kinds go through the same narrowing as announced ones.
|
||||
let restriction = std::mem::take(&mut self.restriction);
|
||||
self.restrict(restriction);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -426,4 +565,122 @@ mod tests {
|
||||
c.unregister("workspace/willRenameFiles");
|
||||
assert!(c.file_operation("willRename").is_none());
|
||||
}
|
||||
|
||||
fn restriction(features: &[&str], kinds: &[&str]) -> Restriction {
|
||||
let owned = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
Restriction::new("test", &owned(features), &owned(kinds))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_drop_other_capabilities() {
|
||||
let mut c = ServerCaps::parse(json!({
|
||||
"textDocumentSync": 2,
|
||||
"completionProvider": {},
|
||||
"hoverProvider": true,
|
||||
"documentFormattingProvider": true,
|
||||
"foldingRangeProvider": true,
|
||||
"codeActionProvider": { "resolveProvider": true },
|
||||
"executeCommandProvider": { "commands": ["rope.extract"] },
|
||||
"semanticTokensProvider": { "legend": { "tokenTypes": [], "tokenModifiers": [] }, "full": true },
|
||||
"workspace": { "fileOperations": { "willRename": { "filters": [] } } },
|
||||
"experimental": { "x": true }
|
||||
}));
|
||||
c.restrict(restriction(&["code-action", "nope"], &[]));
|
||||
assert!(c.has("codeActionProvider") && c.has_sub("codeActionProvider", "resolveProvider"));
|
||||
assert_eq!(c.commands(), ["rope.extract"]);
|
||||
for key in ["completionProvider", "hoverProvider", "documentFormattingProvider", "foldingRangeProvider"] {
|
||||
assert!(!c.has(key), "{key}");
|
||||
}
|
||||
assert!(c.semantic_legend.is_none() && !c.semantic_tokens_full());
|
||||
assert_eq!((c.sync, c.open_close), (SyncKind::Incremental, true));
|
||||
assert!(c.file_operation("willRename").is_some() && c.raw["experimental"]["x"] == true);
|
||||
assert!(!c.restriction.diagnostics());
|
||||
assert_eq!(c.code_action_kinds(), None, "no kind filter: the server may return any kind");
|
||||
|
||||
// Later registrations go through the same filter.
|
||||
c.register("textDocument/hover", None);
|
||||
c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true })));
|
||||
assert!(!c.has("hoverProvider") && !c.has("semanticTokensProvider"));
|
||||
c.register("textDocument/didSave", None);
|
||||
assert_eq!(c.save, Some(false));
|
||||
|
||||
let mut c = ServerCaps::parse(json!({ "hoverProvider": true, "semanticTokensProvider": { "full": true } }));
|
||||
c.restrict(Restriction::default());
|
||||
assert!(c.has("hoverProvider") && c.restriction.diagnostics(), "no `features`: everything");
|
||||
c.restrict(restriction(&["semantic-tokens", "diagnostics"], &[]));
|
||||
c.register("textDocument/semanticTokens", Some(&json!({ "legend": {}, "full": true })));
|
||||
assert!(!c.has("hoverProvider") && c.semantic_tokens_full() && c.restriction.diagnostics());
|
||||
c.register("textDocument/hover", None);
|
||||
assert!(!c.has("hoverProvider"), "the restriction survives re-parsing semantic tokens");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_action_kinds_narrow_what_is_asked_and_kept() {
|
||||
let r = restriction(&[], &["refactor", "source.fixAll"]);
|
||||
assert!(r.keeps_action(Some("refactor")) && r.keeps_action(Some("refactor.extract.function")));
|
||||
assert!(r.keeps_action(Some("source.fixAll.ruff")));
|
||||
assert!(!r.keeps_action(Some("refactorx")) && !r.keeps_action(Some("source")) && !r.keeps_action(None));
|
||||
assert!(Restriction::default().keeps_action(None));
|
||||
|
||||
let kinds = |provider: Value| {
|
||||
let mut c = ServerCaps::parse(json!({ "codeActionProvider": provider }));
|
||||
c.restrict(r.clone());
|
||||
c.has("codeActionProvider").then(|| c.code_action_kinds().unwrap())
|
||||
};
|
||||
assert_eq!(kinds(json!(true)), Some(vec!["refactor".into(), "source.fixAll".into()]));
|
||||
assert_eq!(
|
||||
kinds(json!({ "codeActionKinds": ["quickfix", "refactor.extract", "source"] })),
|
||||
Some(vec!["refactor.extract".into(), "source.fixAll".into()]),
|
||||
);
|
||||
assert_eq!(kinds(json!({ "codeActionKinds": ["quickfix"] })), None, "nothing it lists is kept");
|
||||
|
||||
let mut c = ServerCaps::parse(json!({}));
|
||||
c.restrict(r.clone());
|
||||
c.register("textDocument/codeAction", Some(&json!({ "codeActionKinds": ["quickfix", "refactor.inline"] })));
|
||||
assert_eq!(c.code_action_kinds(), Some(vec!["refactor.inline".into()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_definitions_use_known_feature_names() {
|
||||
for lang in ide_language::LanguageRegistry::load(None).unwrap().all() {
|
||||
for server in &lang.language_servers {
|
||||
for name in &server.features {
|
||||
assert!(FEATURES.iter().any(|(n, _)| n == name), "{}: unknown feature `{name}`", server.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feature_names_cover_every_capability_the_ide_asks_for() {
|
||||
let named: Vec<&str> = FEATURES.iter().flat_map(|(_, keys)| keys.iter().copied()).collect();
|
||||
for method in [
|
||||
"textDocument/completion",
|
||||
"textDocument/hover",
|
||||
"textDocument/signatureHelp",
|
||||
"textDocument/definition",
|
||||
"textDocument/declaration",
|
||||
"textDocument/typeDefinition",
|
||||
"textDocument/implementation",
|
||||
"textDocument/references",
|
||||
"textDocument/documentHighlight",
|
||||
"textDocument/documentSymbol",
|
||||
"workspace/symbol",
|
||||
"textDocument/codeAction",
|
||||
"workspace/executeCommand",
|
||||
"textDocument/rename",
|
||||
"textDocument/formatting",
|
||||
"textDocument/rangeFormatting",
|
||||
"textDocument/inlayHint",
|
||||
"textDocument/inlineCompletion",
|
||||
"textDocument/semanticTokens",
|
||||
"textDocument/codeLens",
|
||||
"textDocument/documentLink",
|
||||
"textDocument/prepareCallHierarchy",
|
||||
"textDocument/prepareTypeHierarchy",
|
||||
] {
|
||||
let key = capability_key(method).unwrap();
|
||||
assert!(named.contains(&key), "{method} ({key}) has no feature name");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,10 @@ impl ClientHandler {
|
||||
}
|
||||
|
||||
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
|
||||
// Problems come only from servers used for `diagnostics`.
|
||||
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
|
||||
return;
|
||||
}
|
||||
let params: lsp::PublishDiagnosticsParams = match serde_json::from_value(params) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
|
||||
@@ -156,6 +156,7 @@ impl LspManager {
|
||||
.iter()
|
||||
.zip(ready)
|
||||
.filter_map(|(s, run)| Some(Target { server: s.clone(), run: run? }))
|
||||
.filter(|t| t.caps().restriction.diagnostics())
|
||||
.collect();
|
||||
if targets.is_empty() {
|
||||
return Ok(None);
|
||||
|
||||
@@ -20,6 +20,7 @@ const SERVER: &str = "jdtls";
|
||||
|
||||
pub(crate) mod configuration;
|
||||
mod formatter;
|
||||
mod hierarchy;
|
||||
pub(crate) mod reserved_names;
|
||||
pub use formatter::configure_formatter;
|
||||
/// First start imports the Maven / Gradle model, which can take minutes.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Pull Members Up / Push Members Down through the app-owned JDT extension
|
||||
//! (`plugins/java-usages`). The server checks the move and computes the edit; the UI
|
||||
//! shows its problems and applies the edit itself.
|
||||
use super::COMMAND_TIMEOUT;
|
||||
use crate::types::{HierarchyChoice, HierarchyMembers, HierarchyRefactored, HierarchyRefactoring, RefactoringProblem};
|
||||
use crate::{DocSnap, LspManager, Target, convert};
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, Pos};
|
||||
use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
const MEMBERS: &str = "openidea.java.hierarchy.members";
|
||||
const REFACTOR: &str = "openidea.java.hierarchy.refactor";
|
||||
|
||||
/// The extension's answer: a result, or why the position can't be refactored.
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum Answer<T> {
|
||||
Refused { refused: String },
|
||||
Ok(T),
|
||||
}
|
||||
|
||||
impl<T> Answer<T> {
|
||||
fn into_result(self) -> IdeResult<T> {
|
||||
match self {
|
||||
Answer::Refused { refused } => Err(IdeError::invalid(refused)),
|
||||
Answer::Ok(value) => Ok(value),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Refactored {
|
||||
problems: Vec<RefactoringProblem>,
|
||||
edit: Option<lsp_types::WorkspaceEdit>,
|
||||
}
|
||||
|
||||
fn feature(kind: HierarchyRefactoring) -> &'static str {
|
||||
match kind {
|
||||
HierarchyRefactoring::PullUp => "Pull Members Up",
|
||||
HierarchyRefactoring::PushDown => "Push Members Down",
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
async fn hierarchy_command<R: DeserializeOwned>(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
command: &str,
|
||||
extra: &[Value],
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<(R, DocSnap, Target)> {
|
||||
let (snap, mut targets) =
|
||||
self.doc_targets(doc, feature(kind), |c| c.commands().iter().any(|c| c == command)).await?;
|
||||
let t = targets.swap_remove(0);
|
||||
let at = snap.position(&t, pos);
|
||||
let mut arguments = vec![json!(snap.uri), json!(at.line), json!(at.character), json!(kind)];
|
||||
arguments.extend_from_slice(extra);
|
||||
let params = json!({ "command": command, "arguments": arguments });
|
||||
let answer: Answer<R> =
|
||||
self.call(&t, "workspace/executeCommand", params, Some(cancel), COMMAND_TIMEOUT).await?;
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok((answer.into_result()?, snap, t))
|
||||
}
|
||||
|
||||
/// The class at `pos` and its movable members; an invalid-input error says why there are none.
|
||||
pub async fn hierarchy_members(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<HierarchyMembers> {
|
||||
let (members, _, _) = self.hierarchy_command(doc, pos, kind, MEMBERS, &[], &cancel).await?;
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
/// Checks moving `choices` (to `target`, for pull up) and computes the edit without applying it.
|
||||
pub async fn hierarchy_refactor(
|
||||
&self,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
kind: HierarchyRefactoring,
|
||||
target: Option<String>,
|
||||
choices: Vec<HierarchyChoice>,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<HierarchyRefactored> {
|
||||
let choices: Vec<Value> = choices.iter().map(|c| json!({ "id": c.id, "abstract": c.make_abstract })).collect();
|
||||
let (refactored, _, t): (Refactored, _, _) =
|
||||
self.hierarchy_command(doc, pos, kind, REFACTOR, &[json!(target), json!(choices)], &cancel).await?;
|
||||
let conv = self.conv(t.enc());
|
||||
Ok(HierarchyRefactored {
|
||||
problems: refactored.problems,
|
||||
edit: refactored.edit.map(|e| convert::workspace_edit(&conv, e)),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -570,7 +570,10 @@ impl LspManager {
|
||||
if let Some(info) = result.get("serverInfo") {
|
||||
tracing::info!("{} initialized: {info}", server.name);
|
||||
}
|
||||
ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})))
|
||||
let mut caps = ServerCaps::parse(result.get("capabilities").cloned().unwrap_or_else(|| json!({})));
|
||||
let cfg = server.effective_config();
|
||||
caps.restrict(caps::Restriction::new(&server.name, &cfg.features, &cfg.code_action_kinds));
|
||||
caps
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("{}: initialize failed: {e}", server.name);
|
||||
@@ -1593,8 +1596,14 @@ impl LspManager {
|
||||
Ok(actions) => {
|
||||
let conv = self.conv(t.enc());
|
||||
let acc = out.value.get_or_insert_with(Vec::new);
|
||||
let caps = t.caps();
|
||||
acc.extend(
|
||||
actions.unwrap_or_default().into_iter().map(|a| convert::code_action(&conv, a, t.server.id)),
|
||||
actions
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|a| convert::code_action(&conv, a, t.server.id))
|
||||
// Servers may ignore `only`, and the kinds kept for this one.
|
||||
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
|
||||
);
|
||||
}
|
||||
Err(e) => out.fail(e),
|
||||
@@ -1964,6 +1973,29 @@ impl LspManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Files that changed on disk, outside the editor too (a checkout, another tool): each running
|
||||
/// server hears of those of its languages under its root through `workspace/didChangeWatchedFiles`.
|
||||
/// Without it jdtls's incremental build never sees them, so a launch runs stale classes.
|
||||
pub fn watched_files_changed(&self, created: &[PathBuf], modified: &[PathBuf], removed: &[PathBuf]) {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for s in servers {
|
||||
let Some(run) = s.running() else { continue };
|
||||
let languages = s.languages();
|
||||
let changes: Vec<Value> = [(created, 1), (modified, 2), (removed, 3)]
|
||||
.into_iter()
|
||||
.flat_map(|(paths, kind)| paths.iter().map(move |p| (p, kind)))
|
||||
.filter(|(p, _)| p.starts_with(&s.root) && languages.contains(&self.languages.detect(p, None)))
|
||||
.map(|(p, kind)| json!({ "uri": uri::path_to_uri(p), "type": kind }))
|
||||
.collect();
|
||||
if changes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Err(e) = run.peer.notify("workspace/didChangeWatchedFiles", json!({ "changes": changes })) {
|
||||
tracing::debug!("{}: didChangeWatchedFiles: {e}", s.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks initialized capabilities, including dynamic registrations, before choosing a formatter.
|
||||
pub async fn can_format(&self, doc: DocId) -> IdeResult<bool> {
|
||||
let snap = self.snapshot(doc)?;
|
||||
|
||||
@@ -389,6 +389,87 @@ pub struct Renamed {
|
||||
pub incomplete: Option<ReferencesIncomplete>,
|
||||
}
|
||||
|
||||
/// IDEA's class-hierarchy refactorings.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HierarchyRefactoring {
|
||||
PullUp,
|
||||
PushDown,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyClass {
|
||||
/// Server-owned identity, passed back as the pull-up destination.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub qualified_name: String,
|
||||
pub interface: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum HierarchyMemberKind {
|
||||
Field,
|
||||
Method,
|
||||
Type,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyMember {
|
||||
pub id: String,
|
||||
/// `name(Params) : Type`, as the server renders it.
|
||||
pub label: String,
|
||||
pub kind: HierarchyMemberKind,
|
||||
/// The member at the caret.
|
||||
pub selected: bool,
|
||||
/// Can be declared abstract in the destination (pull up) or kept abstract (push down).
|
||||
pub abstractable: bool,
|
||||
}
|
||||
|
||||
/// The class at a position and what can move: pull-up destinations, or the subclasses
|
||||
/// members are pushed into.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyMembers {
|
||||
pub class_name: String,
|
||||
pub targets: Vec<HierarchyClass>,
|
||||
pub members: Vec<HierarchyMember>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyChoice {
|
||||
pub id: String,
|
||||
pub make_abstract: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ProblemSeverity {
|
||||
Info,
|
||||
Warning,
|
||||
Error,
|
||||
/// The refactoring can't run at all; there is no edit.
|
||||
Fatal,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RefactoringProblem {
|
||||
pub severity: ProblemSeverity,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// A checked refactoring: what the server found, and the edit unless a problem is fatal.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct HierarchyRefactored {
|
||||
pub problems: Vec<RefactoringProblem>,
|
||||
pub edit: Option<WorkspaceEdit>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CallHierarchy {
|
||||
|
||||
@@ -225,6 +225,8 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
install_hint: Some(format!("install {name}")),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
@@ -403,6 +405,37 @@ async fn linked_projects_initialize_separate_language_server_roots() {
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn files_changed_on_disk_reach_the_servers_of_their_language() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
let s = h.launcher.state("mock");
|
||||
let (created, modified, removed) = (h.root.join("new.mk"), h.root.join("a.mk"), h.root.join("old.mk"));
|
||||
// Other languages and files outside the server's root are left out.
|
||||
let other = h.root.join("notes.txt");
|
||||
let outside = tempfile::tempdir().unwrap().path().join("far.mk");
|
||||
h.mgr.watched_files_changed(
|
||||
&[created.clone(), other],
|
||||
&[modified.clone(), outside],
|
||||
std::slice::from_ref(&removed),
|
||||
);
|
||||
eventually(|| !s.notified("workspace/didChangeWatchedFiles").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeWatchedFiles")[0]["changes"],
|
||||
json!([
|
||||
{ "uri": uri_of(&created), "type": 1 },
|
||||
{ "uri": uri_of(&modified), "type": 2 },
|
||||
{ "uri": uri_of(&removed), "type": 3 },
|
||||
])
|
||||
);
|
||||
// Nothing of its languages: no notification.
|
||||
h.mgr.watched_files_changed(&[h.root.join("b.txt")], &[], &[]);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
|
||||
assert_eq!(s.notified("workspace/didChangeWatchedFiles").len(), 1);
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn initialize_and_incremental_sync() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -1749,6 +1782,64 @@ async fn code_action_query_without_listed_kinds() {
|
||||
assert_eq!(q.advertised_kinds, None, "`codeActionProvider` without `codeActionKinds` may return any kind");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn features_limit_what_a_server_is_used_for() {
|
||||
// A refactoring-only server listed first, announcing everything the main one does.
|
||||
let h = harness_configured("mock", "mk", &[("rope", incremental_caps()), ("main", incremental_caps())], |cfg| {
|
||||
if cfg.name == "rope" {
|
||||
cfg.features = vec!["code-action".into()];
|
||||
cfg.code_action_kinds = vec!["refactor".into()];
|
||||
}
|
||||
});
|
||||
let (doc, path) = h.open("a.mk", "let x = 1;\n");
|
||||
for name in ["rope", "main"] {
|
||||
h.wait_status(name, ServerStatus::Ready).await;
|
||||
}
|
||||
let (rope, main) = (h.launcher.state("rope"), h.launcher.state("main"));
|
||||
for s in [&rope, &main] {
|
||||
s.respond("textDocument/hover", json!({ "contents": "x" }));
|
||||
s.respond("textDocument/formatting", json!([]));
|
||||
}
|
||||
|
||||
h.mgr.hover(doc, Pos::new(0, 4), Default::default()).await.unwrap();
|
||||
assert_eq!(main.requested("textDocument/hover").len(), 1);
|
||||
h.mgr
|
||||
.format(doc, None, FormatOptions { tab_size: 2, insert_spaces: true, on_save: false, tool: None })
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(main.requested("textDocument/formatting").len(), 1);
|
||||
assert!(rope.requested("textDocument/hover").is_empty() && rope.requested("textDocument/formatting").is_empty());
|
||||
|
||||
rope.respond(
|
||||
"textDocument/codeAction",
|
||||
json!([
|
||||
{ "title": "Organize imports", "kind": "source.organizeImports" },
|
||||
{ "title": "Extract variable", "kind": "refactor.extract.variable" },
|
||||
{ "title": "Run", "command": "mock.run" }
|
||||
]),
|
||||
);
|
||||
main.respond("textDocument/codeAction", json!([{ "title": "Fix", "kind": "quickfix" }]));
|
||||
let q = h.mgr.code_action_query(doc, rng(0, 8, 0, 9), vec![], ide_core::CancellationToken::new()).await.unwrap();
|
||||
let mut titles: Vec<&str> = q.actions.iter().map(|a| a.title.as_str()).collect();
|
||||
titles.sort();
|
||||
assert_eq!(titles, ["Extract variable", "Fix"], "rope's other kinds and bare commands are dropped");
|
||||
|
||||
let diag =
|
||||
json!([{ "range": {"start":{"line":0,"character":4},"end":{"line":0,"character":5}}, "message": "unused" }]);
|
||||
rope.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
|
||||
.unwrap();
|
||||
main.peer()
|
||||
.notify("textDocument/publishDiagnostics", json!({ "uri": uri_of(&path), "diagnostics": diag }))
|
||||
.unwrap();
|
||||
eventually(|| {
|
||||
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "main"))
|
||||
})
|
||||
.await;
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
assert!(!h.events.lock().iter().any(|e| matches!(e, IdeEvent::Diagnostics { source, .. } if source == "rope")));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn shutdown_all_stops_gracefully() {
|
||||
let h = harness(&[("mock", incremental_caps())]);
|
||||
@@ -3247,3 +3338,90 @@ async fn jdtls_import_failure_names_reserved_device_files_on_windows() {
|
||||
}
|
||||
let _ = std::fs::remove_file(&nul);
|
||||
}
|
||||
|
||||
/// Pull Members Up / Push Members Down: the extension's answers, refusals and edits.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_hierarchy_refactorings_use_the_extension_commands() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] =
|
||||
json!({"commands": ["openidea.java.hierarchy.members", "openidea.java.hierarchy.refactor"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
*s.responder.lock() = Some(Arc::new(|_, method, params| {
|
||||
if method != "workspace/executeCommand" {
|
||||
return None;
|
||||
}
|
||||
let args = params["arguments"].as_array().unwrap();
|
||||
let uri = args[0].clone();
|
||||
Some(match (params["command"].as_str().unwrap(), args[1].as_u64().unwrap()) {
|
||||
(_, 9) => json!({"refused": "Type has no super types."}),
|
||||
("openidea.java.hierarchy.members", _) => json!({
|
||||
"className": "Dog",
|
||||
"targets": [{"id": "=p/src<{Animal.java[Animal", "name": "Animal", "qualifiedName": "Animal", "interface": false}],
|
||||
"members": [{"id": "=p/src<{Dog.java[Dog~bark", "label": "bark() : String", "kind": "method", "selected": true, "abstractable": true}],
|
||||
}),
|
||||
_ => json!({
|
||||
"problems": [{"severity": "warning", "message": "The visibility of field 'tail' will be changed to package."}],
|
||||
"edit": {"changes": {uri.as_str().unwrap(): [
|
||||
{"range": {"start": {"line": 0, "character": 0}, "end": {"line": 0, "character": 5}}, "newText": "class"}
|
||||
]}},
|
||||
}),
|
||||
})
|
||||
}));
|
||||
let text =
|
||||
format!("class Dog extends Animal {{ String bark() {{ return \"\"; }} }}{}class Lonely {{}}", "\n".repeat(9));
|
||||
let (doc, path) = h.open("Dog.java", &text);
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
|
||||
let info =
|
||||
h.mgr.hierarchy_members(doc, Pos::new(0, 35), HierarchyRefactoring::PullUp, Default::default()).await.unwrap();
|
||||
assert_eq!(info.class_name, "Dog");
|
||||
assert_eq!(info.targets[0].name, "Animal");
|
||||
assert!(info.members[0].selected && info.members[0].abstractable);
|
||||
assert_eq!(info.members[0].kind, HierarchyMemberKind::Method);
|
||||
let sent = s.requested("workspace/executeCommand");
|
||||
assert_eq!(sent[0]["arguments"], json!([uri_of(&path), 0, 35, "pullUp"]));
|
||||
|
||||
let choices = vec![HierarchyChoice { id: info.members[0].id.clone(), make_abstract: true }];
|
||||
let done = h
|
||||
.mgr
|
||||
.hierarchy_refactor(
|
||||
doc,
|
||||
Pos::new(0, 35),
|
||||
HierarchyRefactoring::PushDown,
|
||||
Some(info.targets[0].id.clone()),
|
||||
choices,
|
||||
Default::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(done.problems[0].severity, ProblemSeverity::Warning);
|
||||
let edit = done.edit.unwrap();
|
||||
assert!(matches!(&edit.files[..], [FileEdit::Edit { path: p, edits, .. }] if p == &path && edits.len() == 1));
|
||||
let sent = s.requested("workspace/executeCommand");
|
||||
assert_eq!(
|
||||
sent[1]["arguments"],
|
||||
json!([uri_of(&path), 0, 35, "pushDown", "=p/src<{Animal.java[Animal",
|
||||
[{"id": "=p/src<{Dog.java[Dog~bark", "abstract": true}]])
|
||||
);
|
||||
|
||||
let refused = h.mgr.hierarchy_members(doc, Pos::new(9, 0), HierarchyRefactoring::PullUp, Default::default()).await;
|
||||
let e = refused.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput);
|
||||
assert_eq!(e.message, "Type has no super types.");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn hierarchy_refactorings_name_the_servers_without_them() {
|
||||
let h = harness(&[("plain", incremental_caps())]);
|
||||
let (doc, _) = h.open("a.mk", "class A {}");
|
||||
h.wait_status("plain", ServerStatus::Ready).await;
|
||||
let e = h
|
||||
.mgr
|
||||
.hierarchy_members(doc, Pos::new(0, 6), HierarchyRefactoring::PullUp, Default::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
|
||||
assert_eq!(e.message, "plain doesn't support Pull Members Up");
|
||||
}
|
||||
@@ -129,6 +129,8 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
install_hint: Some("npm i -g nope-ls".into()),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
@@ -396,6 +398,8 @@ async fn typescript_language_server_end_to_end() {
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
@@ -443,6 +447,8 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
|
||||
@@ -52,11 +52,16 @@ fn local_folder(local: &str, project_root: &Path) -> Option<PathBuf> {
|
||||
{
|
||||
return Some(project_root.join(rel));
|
||||
}
|
||||
let rel = local.trim_matches('/');
|
||||
let relative = project_root.join(rel);
|
||||
let relative = project_root.join(without_root(path));
|
||||
if !path.is_absolute() || relative.is_dir() || !path.exists() { Some(relative) } else { None }
|
||||
}
|
||||
|
||||
/// `path` without its drive and root (`/a/b`, `C:\a\b` → `a/b`), to read it
|
||||
/// relative to the project: joining an absolute path would replace the root.
|
||||
fn without_root(path: &Path) -> PathBuf {
|
||||
path.components().filter(|c| !matches!(c, Component::Prefix(_) | Component::RootDir)).collect()
|
||||
}
|
||||
|
||||
impl Mappings {
|
||||
/// `remote_root` is the server root resolved on the host (its login
|
||||
/// directory when the setting is empty). A server without mappings maps the
|
||||
@@ -247,14 +252,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn local_paths_may_be_absolute_inside_the_project() {
|
||||
let root = Path::new("/p");
|
||||
let m = Mappings::new(&server(vec![("/p", "/", ""), ("/p/static/", "cdn", "")]), root, "/mnt/deploy");
|
||||
assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
|
||||
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
|
||||
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(PathBuf::from("/p/static/x.css")));
|
||||
let root = Path::new(if cfg!(windows) { r"C:\p" } else { "/p" });
|
||||
let (inside, statics) = (root.to_str().unwrap(), format!("{}/static/", root.display()));
|
||||
let m = Mappings::new(&server(vec![(inside, "/", ""), (&statics, "cdn", "")]), root, "/mnt/deploy");
|
||||
assert_eq!(m.remote(&root.join("src/a.py")).as_deref(), Some("/mnt/deploy/src/a.py"));
|
||||
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/cdn/x.css"));
|
||||
assert_eq!(m.local("/mnt/deploy/cdn/x.css"), Some(root.join("static/x.css")));
|
||||
// A leading `/` that is not the project stays project-relative.
|
||||
let m = Mappings::new(&server(vec![("/static/", "", "")]), root, "/mnt/deploy");
|
||||
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
|
||||
assert_eq!(m.remote(&root.join("static/x.css")).as_deref(), Some("/mnt/deploy/x.css"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -264,7 +270,7 @@ mod tests {
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
let nested = root.join(outside.strip_prefix("/").unwrap()).join("a.txt");
|
||||
let nested = root.join(without_root(&outside)).join("a.txt");
|
||||
std::fs::create_dir_all(nested.parent().unwrap().parent().unwrap()).unwrap();
|
||||
// A nested project file at the outside path's spelling is not a mapping folder.
|
||||
std::fs::write(nested.parent().unwrap(), "nested").unwrap();
|
||||
@@ -288,7 +294,7 @@ mod tests {
|
||||
assert_eq!(local_folder("/", &root), Some(root.clone()));
|
||||
let outside = tmp.path().join("outside");
|
||||
std::fs::create_dir(&outside).unwrap();
|
||||
let relative = root.join(outside.strip_prefix("/").unwrap());
|
||||
let relative = root.join(without_root(&outside));
|
||||
std::fs::create_dir_all(&relative).unwrap();
|
||||
assert_eq!(local_folder(outside.to_str().unwrap(), &root), Some(relative.clone()));
|
||||
let nested = relative.join("a.txt");
|
||||
@@ -297,7 +303,7 @@ mod tests {
|
||||
assert_eq!(m.remote(&nested).as_deref(), Some("/srv/a.txt"));
|
||||
assert_eq!(m.local("/srv/a.txt"), Some(nested));
|
||||
let missing = tmp.path().join("missing");
|
||||
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(missing.strip_prefix("/").unwrap())));
|
||||
assert_eq!(local_folder(missing.to_str().unwrap(), &root), Some(root.join(without_root(&missing))));
|
||||
assert_eq!(local_folder("/src/", &root), Some(root.join("src")));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ fn relative(root: &Path, dir: &Path) -> PathBuf {
|
||||
|
||||
// ---------------------------------------------------------------- node
|
||||
|
||||
fn package_manager(root: &Path, pkg: &Json) -> &'static str {
|
||||
pub(crate) fn package_manager(root: &Path, pkg: &Json) -> &'static str {
|
||||
if let Some(pm) = pkg.get("packageManager").and_then(Json::as_str) {
|
||||
match pm.split('@').next().unwrap_or_default() {
|
||||
"pnpm" => return "pnpm",
|
||||
@@ -172,7 +172,7 @@ fn package_manager(root: &Path, pkg: &Json) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_test_script(name: &str) -> bool {
|
||||
pub(crate) fn is_test_script(name: &str) -> bool {
|
||||
matches!(name, "test" | "vitest" | "jest") || name.starts_with("test:")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,922 @@
|
||||
//! JetBrains run configurations (PyCharm, WebStorm, IntelliJ IDEA): the shared `.run/*.run.xml`
|
||||
//! files, `.idea/runConfigurations/*.xml`, and the `RunManager` of `.idea/workspace.xml` without
|
||||
//! 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.
|
||||
//! `$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.
|
||||
|
||||
use super::{
|
||||
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, relative_to,
|
||||
working_dir,
|
||||
};
|
||||
use crate::RunConfig;
|
||||
use ide_core::IdeResult;
|
||||
use quick_xml::{Reader, XmlVersion, events::Event};
|
||||
use std::cell::Cell;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// The files read, in order of precedence, with the name the summary gives them.
|
||||
pub(crate) fn files(root: &Path) -> Vec<(String, PathBuf)> {
|
||||
let mut out = Vec::new();
|
||||
for (dir, suffix) in [(".run", ".run.xml"), (".idea/runConfigurations", ".xml")] {
|
||||
let mut found: Vec<PathBuf> = std::fs::read_dir(root.join(dir))
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.file_name().is_some_and(|n| n.to_string_lossy().ends_with(suffix)))
|
||||
.collect();
|
||||
found.sort();
|
||||
for p in found {
|
||||
let name = p.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
out.push((format!("{dir}/{name}"), p));
|
||||
}
|
||||
}
|
||||
// IDEA writes a workspace.xml in every project it opens: it counts only with configurations to import.
|
||||
let workspace = root.join(".idea/workspace.xml");
|
||||
let has_configurations = std::fs::read_to_string(&workspace).is_ok_and(|text| {
|
||||
text.contains("RunManager")
|
||||
&& parse(&text).map_or(true, |doc| configurations(&doc, "RunManager").next().is_some())
|
||||
});
|
||||
if has_configurations {
|
||||
out.push((".idea/workspace.xml".into(), workspace));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The configurations of `manager` (`RunManager` in workspace.xml), without IDEA's templates for
|
||||
/// new configurations and the temporary ones it makes for a single run.
|
||||
fn configurations<'a>(doc: &'a El, manager: &'a str) -> impl Iterator<Item = &'a El> {
|
||||
std::iter::once(doc)
|
||||
.chain(doc.children.iter())
|
||||
.filter(move |c| c.name == "component" && c.attr("name") == Some(manager))
|
||||
.flat_map(|component| component.children_named("configuration"))
|
||||
.filter(|c| c.attr("default") != Some("true") && c.attr("temporary") != Some("true"))
|
||||
}
|
||||
|
||||
pub(crate) fn entries(root: &Path, env: &BTreeMap<String, String>) -> IdeResult<Vec<Entry>> {
|
||||
let windows = cfg!(windows);
|
||||
let homes = if windows { ["USERPROFILE", "HOME"] } else { ["HOME", "USERPROFILE"] };
|
||||
let home = homes.into_iter().find_map(|k| env_var(env, k, windows));
|
||||
let cx = Cx { root: &normalize(root), home, modules: module_count(root), machine_values: Cell::new(false) };
|
||||
let mut out = Vec::new();
|
||||
for (label, path) in files(root) {
|
||||
let doc = std::fs::read_to_string(&path).map_err(|e| e.to_string()).and_then(|text| parse(&text));
|
||||
let doc = match doc {
|
||||
Ok(doc) => doc,
|
||||
Err(detail) => {
|
||||
out.push(Entry { name: label, result: Err(SkipReason::InvalidFile { detail }) });
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let workspace = label == ".idea/workspace.xml";
|
||||
let manager = if workspace { "RunManager" } else { "ProjectRunConfigurationManager" };
|
||||
out.extend(configurations(&doc, manager).map(|c| cx.entry(c)));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------- XML
|
||||
|
||||
/// An element of the file, with its attributes (values unescaped) and child elements.
|
||||
#[derive(Debug, Default)]
|
||||
struct El {
|
||||
name: String,
|
||||
attrs: Vec<(String, String)>,
|
||||
children: Vec<El>,
|
||||
}
|
||||
|
||||
impl El {
|
||||
fn attr(&self, key: &str) -> Option<&str> {
|
||||
self.attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.as_str())
|
||||
}
|
||||
|
||||
fn child(&self, name: &str) -> Option<&El> {
|
||||
self.children.iter().find(|c| c.name == name)
|
||||
}
|
||||
|
||||
fn children_named<'a>(&'a self, name: &'a str) -> impl Iterator<Item = &'a El> {
|
||||
self.children.iter().filter(move |c| c.name == name)
|
||||
}
|
||||
|
||||
/// `<option name="KEY" value="…"/>`, a direct child.
|
||||
fn option(&self, key: &str) -> Option<&str> {
|
||||
self.children_named("option").find(|o| o.attr("name") == Some(key)).and_then(|o| o.attr("value"))
|
||||
}
|
||||
|
||||
/// `<KEY value="…"/>` (the WebStorm types write their fields so).
|
||||
fn value(&self, key: &str) -> Option<&str> {
|
||||
self.child(key).and_then(|c| c.attr("value"))
|
||||
}
|
||||
|
||||
fn flag(&self, key: &str) -> bool {
|
||||
self.option(key) == Some("true")
|
||||
}
|
||||
}
|
||||
|
||||
fn parse(xml: &str) -> Result<El, String> {
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut stack: Vec<El> = vec![El::default()];
|
||||
loop {
|
||||
let event = reader.read_event().map_err(|e| e.to_string())?;
|
||||
match event {
|
||||
Event::Start(ref tag) | Event::Empty(ref tag) => {
|
||||
let mut el = El { name: tag.name().as_ref().to_owned(), ..El::default() };
|
||||
for a in tag.attributes() {
|
||||
let a = a.map_err(|e| e.to_string())?;
|
||||
let value = a.normalized_value(XmlVersion::Implicit1_0).map_err(|e| e.to_string())?;
|
||||
el.attrs.push((a.key.as_ref().to_owned(), value.into_owned()));
|
||||
}
|
||||
if matches!(event, Event::Start(_)) {
|
||||
stack.push(el);
|
||||
} else if let Some(parent) = stack.last_mut() {
|
||||
parent.children.push(el);
|
||||
}
|
||||
}
|
||||
Event::End(_) => {
|
||||
let el = stack.pop().ok_or("unbalanced element")?;
|
||||
stack.last_mut().ok_or("unbalanced element")?.children.push(el);
|
||||
}
|
||||
Event::Eof => break,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut document = stack.pop().filter(|_| stack.is_empty()).ok_or("unclosed element")?;
|
||||
document.children.pop().ok_or_else(|| "no root element".to_owned())
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- mapping
|
||||
|
||||
const PYTHON: &str = "PythonConfigurationType";
|
||||
const DJANGO_SERVER: &str = "Python.DjangoServer";
|
||||
const DJANGO_TESTS: &str = "DjangoTestsConfigurationType";
|
||||
const PYTHON_TESTS: &str = "tests";
|
||||
const NPM: &str = "js.build_tools.npm";
|
||||
const JEST: &str = "JavaScriptTestRunnerJest";
|
||||
const VITEST: &str = "JavaScriptTestRunnerVitest";
|
||||
const NODE: &str = "NodeJSConfigurationType";
|
||||
const SHELL: &str = "ShConfigurationType";
|
||||
const COMPOUND: &str = "CompoundRunConfigurationType";
|
||||
|
||||
type Res<T> = Result<T, SkipReason>;
|
||||
|
||||
/// What a `$NAME$` that isn't a known path macro does.
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Unknown {
|
||||
/// Skips the configuration: a path variable defined in the IDE, with no value here.
|
||||
Skip,
|
||||
/// Stays as written: script text, where `$` belongs to the shell.
|
||||
Keep,
|
||||
}
|
||||
|
||||
struct Cx<'a> {
|
||||
root: &'a Path,
|
||||
home: Option<String>,
|
||||
/// Modules `.idea/modules.xml` lists. `$MODULE_DIR$` is the module's content root: the project
|
||||
/// directory in a single-module project, unknown here with several.
|
||||
modules: usize,
|
||||
/// The configuration being converted used `$USER_HOME$`: its values belong to this machine.
|
||||
machine_values: Cell<bool>,
|
||||
}
|
||||
|
||||
/// A JSON string as the pytest configuration stores its `_new_*` fields (`"\"tests\""`).
|
||||
fn json_string(raw: &str) -> String {
|
||||
serde_json::from_str::<String>(raw).unwrap_or_else(|_| raw.to_owned())
|
||||
}
|
||||
|
||||
/// A command line the run configuration forms parse back into `args` (`formatArgs` in the UI):
|
||||
/// single quotes only where needed.
|
||||
fn format_args(args: &[String]) -> String {
|
||||
let quote = |a: &String| {
|
||||
if a.is_empty() {
|
||||
"''".to_owned()
|
||||
} else if a.chars().any(|c| c.is_whitespace() || "'\"\\$`".contains(c)) {
|
||||
format!("'{}'", a.replace('\'', "'\\''"))
|
||||
} else {
|
||||
a.clone()
|
||||
}
|
||||
};
|
||||
args.iter().map(quote).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Regex-escaped for Jest / Vitest `-t`.
|
||||
fn regex_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for c in s.chars() {
|
||||
if r"\^$.|?*+()[]{}".contains(c) {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Cx<'_> {
|
||||
/// Replaces the path macros in `s`; another `$NAME$` skips the configuration.
|
||||
fn expand(&self, s: &str) -> Res<String> {
|
||||
self.expand_with(s, Unknown::Skip)
|
||||
}
|
||||
|
||||
/// Replaces the path macros in `s`, which IDEA writes in every option of a configuration
|
||||
/// (script text included); `unknown` says what another `$NAME$` does.
|
||||
fn expand_with(&self, s: &str, unknown: Unknown) -> Res<String> {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(start) = rest.find('$') {
|
||||
let after = &rest[start + 1..];
|
||||
let len =
|
||||
after.find('$').filter(|&n| n > 0 && after[..n].chars().all(|c| c.is_ascii_alphanumeric() || c == '_'));
|
||||
let Some(len) = len else {
|
||||
out.push_str(&rest[..=start]);
|
||||
rest = after;
|
||||
continue;
|
||||
};
|
||||
out.push_str(&rest[..start]);
|
||||
let name = &after[..len];
|
||||
let value = match name {
|
||||
"PROJECT_DIR" | "ContentRoot" | "ProjectFileDir" => self.root.to_string_lossy().into_owned(),
|
||||
// Which module's directory is unknown here when there are several.
|
||||
"MODULE_DIR" | "MODULE_WORKING_DIR" if self.modules > 1 => {
|
||||
return Err(SkipReason::SeveralModules { variable: format!("${name}$") });
|
||||
}
|
||||
"MODULE_DIR" | "MODULE_WORKING_DIR" => self.root.to_string_lossy().into_owned(),
|
||||
"USER_HOME" => {
|
||||
self.machine_values.set(true);
|
||||
self.home.clone().ok_or(SkipReason::PathVariable { variable: format!("${name}$") })?
|
||||
}
|
||||
_ => match unknown {
|
||||
Unknown::Skip => return Err(SkipReason::PathVariable { variable: format!("${name}$") }),
|
||||
Unknown::Keep => format!("${name}$"),
|
||||
},
|
||||
};
|
||||
out.push_str(&value);
|
||||
rest = &after[len + 1..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn text(&self, value: Option<&str>) -> Res<Option<String>> {
|
||||
match value.map(str::trim) {
|
||||
Some(v) if !v.is_empty() => self.expand(v).map(Some),
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// Words of a parameters field, split like IDEA's `ParametersListUtil.parse`.
|
||||
fn words(&self, value: Option<&str>) -> Res<Vec<String>> {
|
||||
self.words_quoted(value, Quotes::Double)
|
||||
}
|
||||
|
||||
fn words_quoted(&self, value: Option<&str>, quotes: Quotes) -> Res<Vec<String>> {
|
||||
Ok(self.text(value)?.map(|v| parse_params(&v, quotes)).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Words of a command line, paths inside the project relative to `dir`.
|
||||
fn args(&self, value: Option<&str>, dir: &Path) -> Res<Vec<String>> {
|
||||
self.args_quoted(value, Quotes::Double, dir)
|
||||
}
|
||||
|
||||
fn args_quoted(&self, value: Option<&str>, quotes: Quotes, dir: &Path) -> Res<Vec<String>> {
|
||||
Ok(self.words_quoted(value, quotes)?.into_iter().map(|a| path_from(self.root, a, dir)).collect())
|
||||
}
|
||||
|
||||
/// The working directory (absolute) and the configuration's `cwd`.
|
||||
fn cwd(&self, value: Option<&str>) -> Res<(PathBuf, Option<PathBuf>)> {
|
||||
Ok(match self.text(value)? {
|
||||
Some(dir) => working_dir(self.root, &dir),
|
||||
None => (self.root.to_path_buf(), None),
|
||||
})
|
||||
}
|
||||
|
||||
fn entry(&self, c: &El) -> Entry {
|
||||
let name = c.attr("name").unwrap_or_default().trim().to_owned();
|
||||
self.machine_values.set(false);
|
||||
let result = if name.is_empty() { Err(SkipReason::Invalid) } else { self.convert(&name, c) };
|
||||
let result = result.map(|mut found| {
|
||||
// Values of this machine (its home) stay out of the shared run.toml.
|
||||
found.config.extras.local = self.machine_values.get();
|
||||
if let Some(folder) = c.attr("folderName").filter(|f| !f.trim().is_empty()) {
|
||||
found.config.extras.folder = Some(folder.trim().to_owned());
|
||||
}
|
||||
// IDEA's "Allow multiple instances".
|
||||
found.config.allow_parallel = c.attr("singleton") == Some("false");
|
||||
if c.child("method").is_some_and(|m| !m.children.is_empty()) {
|
||||
found.ignored.push("before launch".into());
|
||||
}
|
||||
found.ignored.sort();
|
||||
found
|
||||
});
|
||||
Entry { name, result }
|
||||
}
|
||||
|
||||
fn convert(&self, name: &str, c: &El) -> Res<Found> {
|
||||
let ty = c.attr("type").unwrap_or_default();
|
||||
let factory = c.attr("factoryName").unwrap_or_default();
|
||||
let mut ignored = Vec::new();
|
||||
let mut members = Vec::new();
|
||||
let config = match ty {
|
||||
PYTHON => self.python(name, c, &mut ignored)?,
|
||||
DJANGO_SERVER => self.django_server(name, c, &mut ignored)?,
|
||||
DJANGO_TESTS => self.django_tests(name, c, &mut ignored)?,
|
||||
PYTHON_TESTS if matches!(factory, "py.test" | "pytest") => self.pytest(name, c, &mut ignored)?,
|
||||
NPM => self.npm(name, c, &mut ignored)?,
|
||||
JEST | VITEST => self.js_tests(name, c, ty == VITEST, &mut ignored)?,
|
||||
NODE => self.node(name, c, &mut ignored)?,
|
||||
SHELL => self.shell(name, c)?,
|
||||
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)
|
||||
}
|
||||
PYTHON_TESTS => return Err(SkipReason::UnsupportedType { type_: format!("{ty} ({factory})") }),
|
||||
other => return Err(SkipReason::UnsupportedType { type_: other.to_owned() }),
|
||||
};
|
||||
Ok(Found { config, members, ignored })
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Python
|
||||
|
||||
/// The interpreter (`SDK_HOME` when the configuration doesn't use the module's), its
|
||||
/// options, and the working directory.
|
||||
fn interpreter(&self, c: &El, ignored: &mut Vec<String>) -> Res<(String, Vec<String>, PathBuf, Option<PathBuf>)> {
|
||||
let (dir, cwd) = self.cwd(c.option("WORKING_DIRECTORY"))?;
|
||||
let sdk = if c.flag("IS_MODULE_SDK") { None } else { self.text(c.option("SDK_HOME"))? };
|
||||
let (exe, mut args) = match sdk {
|
||||
Some(sdk) => (path_from(self.root, sdk, &dir), Vec::new()),
|
||||
None => project_python(self.root, &dir),
|
||||
};
|
||||
args.extend(self.words(c.option("INTERPRETER_OPTIONS"))?);
|
||||
// A flag (`AbstractPythonRunConfiguration.java:320-321`): `false` leaves nothing out.
|
||||
if c.option("RUN_TOOL") == Some("true") {
|
||||
ignored.push("RUN_TOOL".into());
|
||||
}
|
||||
Ok((exe, args, dir, cwd))
|
||||
}
|
||||
|
||||
/// `envs`, `PARENT_ENVS`, `ENV_FILES` and the EnvFile plugin's files.
|
||||
fn python_env(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
|
||||
self.envs(c, rc);
|
||||
rc.extras.include_system_env = c.option("PARENT_ENVS") != Some("false");
|
||||
// PyCharm 2024+: "Paths to .env files", joined with `File.pathSeparator`.
|
||||
for file in split_env_files(c.option("ENV_FILES").unwrap_or_default(), PATH_SEPARATOR) {
|
||||
if let Some(file) = self.text(Some(file))? {
|
||||
rc.extras.env_files.push(path_from(self.root, file, self.root));
|
||||
}
|
||||
}
|
||||
self.envfile_plugin(c, rc, ignored)
|
||||
}
|
||||
|
||||
/// Environment variables, as written: IDEA doesn't put path macros there
|
||||
/// (`RunConfigurationPathMacroFilter`), and the Python and Shell types pass `$NAME$` through as is.
|
||||
fn envs(&self, c: &El, rc: &mut RunConfig) {
|
||||
for env in c.child("envs").into_iter().flat_map(|e| e.children_named("env")) {
|
||||
if let Some(key) = env.attr("name").filter(|k| !k.is_empty()) {
|
||||
rc.env.insert(key.to_owned(), env.attr("value").unwrap_or_default().to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The EnvFile plugin (`net.ashald.envfile`): its enabled `.env` entries.
|
||||
fn envfile_plugin(&self, c: &El, rc: &mut RunConfig, ignored: &mut Vec<String>) -> Res<()> {
|
||||
let Some(ext) = c.children_named("extension").find(|e| e.attr("name") == Some("net.ashald.envfile")) else {
|
||||
return Ok(());
|
||||
};
|
||||
if !ext.flag("IS_ENABLED") {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in ext.child("ENTRIES").into_iter().flat_map(|e| e.children_named("ENTRY")) {
|
||||
let path = entry.attr("PATH").unwrap_or_default();
|
||||
if entry.attr("IS_ENABLED") == Some("false") || path.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if entry.attr("PARSER").is_some_and(|p| p != "env") || entry.attr("IS_EXECUTABLE") == Some("true") {
|
||||
ignored.push(format!("EnvFile {path}"));
|
||||
continue;
|
||||
}
|
||||
if let Some(file) = self.text(Some(path))? {
|
||||
rc.extras.env_files.push(path_from(self.root, file, self.root));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn python(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
|
||||
let Some(script) = self.text(c.option("SCRIPT_NAME"))? else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
};
|
||||
if c.flag("MODULE_MODE") {
|
||||
args.extend(["-m".to_owned(), script]);
|
||||
} else {
|
||||
args.push(path_from(self.root, script, &dir));
|
||||
}
|
||||
args.extend(self.args(c.option("PARAMETERS"), &dir)?);
|
||||
let mut rc = config(name, "python", exe, args, Some("debugpy"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
rc.extras.emulate_terminal = c.flag("EMULATE_TERMINAL");
|
||||
if c.flag("REDIRECT_INPUT") {
|
||||
ignored.push("REDIRECT_INPUT".into());
|
||||
}
|
||||
self.python_env(c, &mut rc, ignored)?;
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
/// `manage.py` relative to the working directory: there, at the root, or one level down.
|
||||
fn manage_py(&self, dir: &Path) -> Res<String> {
|
||||
let mut candidates = vec![dir.join("manage.py"), self.root.join("manage.py")];
|
||||
if let Ok(read) = std::fs::read_dir(self.root) {
|
||||
let mut nested: Vec<PathBuf> =
|
||||
read.flatten().map(|e| e.path().join("manage.py")).filter(|p| p.is_file()).collect();
|
||||
if nested.len() == 1 {
|
||||
candidates.append(&mut nested);
|
||||
}
|
||||
}
|
||||
let found =
|
||||
candidates.into_iter().find(|p| p.is_file()).ok_or(SkipReason::MissingFile { file: "manage.py".into() })?;
|
||||
Ok(relative_to(&normalize(&found), dir).unwrap_or_else(|| found.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
fn django_server(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
|
||||
args.push(self.manage_py(&dir)?);
|
||||
let custom = if c.flag("useCustomRunCommand") { self.text(c.option("customRunCommand"))? } else { None };
|
||||
args.push(custom.unwrap_or_else(|| "runserver".into()));
|
||||
let port = self.text(c.option("port"))?;
|
||||
match (self.text(c.option("host"))?, port) {
|
||||
(Some(host), port) => args.push(format!("{host}:{}", port.as_deref().unwrap_or("8000"))),
|
||||
(None, Some(port)) => args.push(port),
|
||||
(None, None) => {}
|
||||
}
|
||||
if c.flag("runNoReload") {
|
||||
args.push("--noreload".into());
|
||||
}
|
||||
args.extend(self.words(c.option("additionalOptions"))?);
|
||||
for key in ["browserUrl", "launchJavascriptDebuger", "runTestServer"] {
|
||||
if c.option(key).is_some_and(|v| !v.is_empty() && v != "false") {
|
||||
ignored.push(key.into());
|
||||
}
|
||||
}
|
||||
let mut rc = config(name, "python", exe, args, Some("debugpy"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
self.python_env(c, &mut rc, ignored)?;
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
fn django_tests(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
|
||||
let manage = self.manage_py(&dir)?;
|
||||
// The Django tests form runs `manage.py` from the working directory; another place stays a Python run.
|
||||
let kind = if manage == "manage.py" { "django-test" } else { "python" };
|
||||
args.extend([manage, "test".to_owned()]);
|
||||
args.extend(self.words(c.option("TARGET"))?);
|
||||
if c.flag("CUSTOM_SETTINGS")
|
||||
&& let Some(settings) = self.text(c.option("SETTINGS_FILE"))?
|
||||
{
|
||||
args.extend(["--settings".to_owned(), settings_module(&settings, self.root, &dir)]);
|
||||
}
|
||||
if c.flag("USE_OPTIONS") {
|
||||
args.extend(self.words(c.option("OPTIONS"))?);
|
||||
}
|
||||
let mut rc = config(name, kind, exe, args, Some("debugpy"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
// A test configuration either way: the test tree reads `manage.py test` output in a Python run too.
|
||||
rc.is_test = true;
|
||||
self.python_env(c, &mut rc, ignored)?;
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
fn pytest(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (exe, mut args, dir, cwd) = self.interpreter(c, ignored)?;
|
||||
args.extend(["-m".to_owned(), "pytest".to_owned()]);
|
||||
let field = |key: &str| c.option(key).map(json_string).filter(|v| !v.trim().is_empty());
|
||||
// As `PyTestConfiguration` (intellij-community `PyTest.kt:151-153`, `:181-183`): parameters only
|
||||
// follow a Python target (`name[params]`), keywords go to `-k` unless the target is custom.
|
||||
let target_type = field("_new_targetType");
|
||||
if let Some(target) = field("_new_target") {
|
||||
let target = self.expand(&target)?;
|
||||
match target_type.as_deref() {
|
||||
Some("PYTHON") => {
|
||||
let params = field("_new_parameters").map(|p| format!("[{p}]")).unwrap_or_default();
|
||||
match self.python_target(&target, &dir) {
|
||||
Some(node_id) => args.push(format!("{node_id}{params}")),
|
||||
// Not a module of the project (an installed package): pytest finds it by name.
|
||||
None => {
|
||||
args.extend(["--pyargs".to_owned(), target]);
|
||||
if !params.is_empty() {
|
||||
ignored.push("_new_parameters".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("CUSTOM") => {}
|
||||
_ => args.push(path_from(self.root, target, &dir)),
|
||||
}
|
||||
}
|
||||
if target_type.as_deref() != Some("CUSTOM")
|
||||
&& let Some(keywords) =
|
||||
field("_new_keywords").map(|k| strip_quotes(&k).to_owned()).filter(|k| !k.is_empty())
|
||||
{
|
||||
args.extend(["-k".to_owned(), keywords]);
|
||||
}
|
||||
// Single quotes group words here too (`PyTestsShared.kt:671`).
|
||||
args.extend(self.args_quoted(field("_new_additionalArguments").as_deref(), Quotes::Single, &dir)?);
|
||||
let mut rc = config(name, "pytest", exe, args, Some("debugpy"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
rc.is_test = true;
|
||||
self.python_env(c, &mut rc, ignored)?;
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
/// A pytest Python target (`tests.test_money.TestRound.test_eur`) as the node id PyCharm
|
||||
/// passes (`tests/test_money.py::TestRound::test_eur`, intellij-community `PyTestsShared.kt:366-400`):
|
||||
/// the longest prefix that is a module on disk (`a/b.py` or `a/b/__init__.py`, from the working
|
||||
/// directory, then the project root), then the rest as `::` symbols. Relative to `dir`.
|
||||
fn python_target(&self, dotted: &str, dir: &Path) -> Option<String> {
|
||||
let parts: Vec<&str> = dotted.split('.').collect();
|
||||
if parts.iter().any(|p| p.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
for base in [dir, self.root] {
|
||||
for n in (1..=parts.len()).rev() {
|
||||
let module = parts[..n].join("/");
|
||||
let (file, node) = if base.join(format!("{module}.py")).is_file() {
|
||||
(base.join(format!("{module}.py")), format!("{module}.py"))
|
||||
} else if base.join(&module).join("__init__.py").is_file() {
|
||||
(base.join(&module), module.clone())
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let path = if base == dir { node } else { path_from(self.root, file.to_string_lossy().into(), dir) };
|
||||
return Some(
|
||||
std::iter::once(path)
|
||||
.chain(parts[n..].iter().map(|s| (*s).to_owned()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("::"),
|
||||
);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- JavaScript
|
||||
|
||||
/// The configuration's own Node interpreter; `None` for the project's (`project`).
|
||||
fn node_interpreter(&self, c: &El) -> Res<Option<String>> {
|
||||
match c.value("node-interpreter").or_else(|| c.attr("node-interpreter")).map(str::trim) {
|
||||
None | Some("" | "project") => Ok(None),
|
||||
interpreter => self.text(interpreter),
|
||||
}
|
||||
}
|
||||
|
||||
/// Kinds without an interpreter field keep the project's and list the setting as ignored.
|
||||
fn node_interpreter_ignored(&self, c: &El, ignored: &mut Vec<String>) -> Res<()> {
|
||||
if self.node_interpreter(c)?.is_some() {
|
||||
ignored.push("node-interpreter".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn npm(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let package_json = self.text(c.value("package-json"))?;
|
||||
let dir = match &package_json {
|
||||
Some(p) => normalize(&self.root.join(p)).parent().map(Path::to_path_buf).unwrap_or(self.root.to_path_buf()),
|
||||
None => self.root.to_path_buf(),
|
||||
};
|
||||
let manager = match self.text(c.value("package-manager"))? {
|
||||
Some(pm) if ["npm", "pnpm", "yarn", "bun"].contains(&command_name(&pm)) => command_name(&pm).to_owned(),
|
||||
_ => {
|
||||
let pkg = std::fs::read_to_string(dir.join("package.json"))
|
||||
.ok()
|
||||
.and_then(|t| serde_json::from_str(&t).ok())
|
||||
.unwrap_or_default();
|
||||
crate::detect::package_manager(&dir, &pkg).to_owned()
|
||||
}
|
||||
};
|
||||
let command = self.text(c.value("command"))?.unwrap_or_else(|| "run".into());
|
||||
let scripts: Vec<&str> = c
|
||||
.child("scripts")
|
||||
.into_iter()
|
||||
.flat_map(|s| s.children_named("script"))
|
||||
.filter_map(|s| s.attr("value"))
|
||||
.collect();
|
||||
let mut args = vec![command.clone()];
|
||||
let mut test = command == "test";
|
||||
if command == "run" {
|
||||
let Some(script) = scripts.first() else { return Err(SkipReason::NothingToRun) };
|
||||
test = crate::detect::is_test_script(script);
|
||||
args.push((*script).to_owned());
|
||||
if scripts.len() > 1 {
|
||||
ignored.push("scripts".into());
|
||||
}
|
||||
}
|
||||
let arguments = self.words(c.value("arguments"))?;
|
||||
args.extend(arguments.iter().cloned());
|
||||
let interpreter = self.node_interpreter(c)?;
|
||||
let package_dir = working_dir(self.root, &dir.to_string_lossy()).1;
|
||||
let mut rc = config(name, "npm", manager.clone(), args, Some("js-debug"));
|
||||
if let Some(interpreter) = interpreter {
|
||||
// The npm form's fields, so the interpreter shows there (and the rest still match).
|
||||
let script = if command == "run" { scripts.first().map(|s| (*s).to_owned()) } else { None };
|
||||
let mut params = BTreeMap::from([
|
||||
("packageManager".to_owned(), manager),
|
||||
("command".to_owned(), command),
|
||||
("script".to_owned(), script.unwrap_or_default()),
|
||||
("arguments".to_owned(), format_args(&arguments)),
|
||||
(
|
||||
"packageDir".to_owned(),
|
||||
package_dir.as_ref().map(|d| d.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
),
|
||||
("nodeInterpreter".to_owned(), interpreter),
|
||||
]);
|
||||
params.retain(|_, v| !v.is_empty());
|
||||
rc.extras.params = params;
|
||||
}
|
||||
rc.cwd = package_dir.map(Into::into);
|
||||
rc.is_test = test;
|
||||
self.envs(c, &mut rc);
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
fn js_tests(&self, name: &str, c: &El, vitest: bool, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.value("working-dir"))?;
|
||||
let mut args: Vec<String> = if vitest { vec!["vitest".into(), "run".into()] } else { vec!["jest".into()] };
|
||||
if !vitest && let Some(config_file) = self.text(c.value("config-file"))? {
|
||||
args.extend(["--config".to_owned(), path_from(self.root, config_file, &dir)]);
|
||||
}
|
||||
args.extend(self.args(c.value(if vitest { "vitest-options" } else { "jest-options" }), &dir)?);
|
||||
match c.value("scope-kind").unwrap_or("ALL") {
|
||||
"DIRECTORY" => args.extend(self.text(c.value("test-directory"))?.map(|d| path_from(self.root, d, &dir))),
|
||||
"TEST_FILE" | "SUITE" | "TEST" => {
|
||||
args.extend(self.text(c.value("test-file"))?.map(|f| path_from(self.root, f, &dir)));
|
||||
let names: Vec<&str> = c
|
||||
.child("test-names")
|
||||
.into_iter()
|
||||
.flat_map(|n| n.children_named("test-name"))
|
||||
.filter_map(|n| n.attr("value"))
|
||||
.collect();
|
||||
if !names.is_empty() {
|
||||
let pattern = names.iter().map(|n| regex_escape(n)).collect::<Vec<_>>().join(" ");
|
||||
let anchored = if c.value("scope-kind") == Some("TEST") {
|
||||
format!("^{pattern}$")
|
||||
} else {
|
||||
format!("^{pattern}")
|
||||
};
|
||||
args.extend(["-t".to_owned(), anchored]);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
self.node_interpreter_ignored(c, ignored)?;
|
||||
let mut rc = config(name, "node", "npx".into(), args, Some("js-debug"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
rc.is_test = true;
|
||||
self.envs(c, &mut rc);
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
fn node(&self, name: &str, c: &El, ignored: &mut Vec<String>) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.attr("working-dir"))?;
|
||||
let Some(file) = self.text(c.attr("path-to-js-file"))? else {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
};
|
||||
let mut args = self.words(c.attr("node-parameters"))?;
|
||||
args.push(path_from(self.root, file, &dir));
|
||||
args.extend(self.args(c.attr("application-parameters"), &dir)?);
|
||||
self.node_interpreter_ignored(c, ignored)?;
|
||||
let mut rc = config(name, "node", "node".into(), args, Some("js-debug"));
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
self.envs(c, &mut rc);
|
||||
rc.extras.include_system_env = c.attr("pass-parent-envs") != Some("false");
|
||||
Ok(rc)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- Shell
|
||||
|
||||
/// A script file (with its interpreter) or script text, kept as the Shell Script form's fields.
|
||||
fn shell(&self, name: &str, c: &El) -> Res<RunConfig> {
|
||||
let (dir, cwd) = self.cwd(c.option("SCRIPT_WORKING_DIRECTORY"))?;
|
||||
let mut params = BTreeMap::new();
|
||||
// Both default to true when missing (`ShRunConfiguration.java:148-149`).
|
||||
let rc = if c.option("EXECUTE_SCRIPT_FILE") != Some("false") {
|
||||
let Some(path) = self.text(c.option("SCRIPT_PATH"))? else { return Err(SkipReason::NothingToRun) };
|
||||
let path = path_from(self.root, path, &dir);
|
||||
let options = self.text(c.option("SCRIPT_OPTIONS"))?.unwrap_or_default();
|
||||
let interpreter =
|
||||
self.text(c.option("INTERPRETER_PATH"))?.map(|i| path_from(self.root, i, &dir)).unwrap_or_default();
|
||||
let interpreter_options = self.text(c.option("INTERPRETER_OPTIONS"))?.unwrap_or_default();
|
||||
// Run directly, the script is the program: `./run.sh`, so it isn't looked up on the PATH.
|
||||
let path = if interpreter.is_empty() { dot_slash(path) } else { path };
|
||||
let (command, args) = if interpreter.is_empty() {
|
||||
(path.clone(), parse_params(&options, Quotes::Double))
|
||||
} else {
|
||||
let mut args = parse_params(&interpreter_options, Quotes::Double);
|
||||
args.push(path.clone());
|
||||
args.extend(parse_params(&options, Quotes::Double));
|
||||
(interpreter.clone(), args)
|
||||
};
|
||||
params.extend([
|
||||
("mode".to_owned(), "file".to_owned()),
|
||||
("path".to_owned(), path),
|
||||
("options".to_owned(), options),
|
||||
("interpreter".to_owned(), interpreter),
|
||||
("interpreterOptions".to_owned(), interpreter_options),
|
||||
]);
|
||||
config(name, "shell", command, args, None)
|
||||
} else {
|
||||
// IDEA writes `$PROJECT_DIR$` / `$USER_HOME$` into the text too; any other `$` is the shell's.
|
||||
let script = self.expand_with(c.option("SCRIPT_TEXT").unwrap_or_default().trim(), Unknown::Keep)?;
|
||||
if script.is_empty() {
|
||||
return Err(SkipReason::NothingToRun);
|
||||
}
|
||||
params.extend([("mode".to_owned(), "text".to_owned()), ("script".to_owned(), script.clone())]);
|
||||
config(name, "shell", script, Vec::new(), None)
|
||||
};
|
||||
let mut rc = rc;
|
||||
params.retain(|_, v| !v.is_empty());
|
||||
rc.extras.params = params;
|
||||
rc.cwd = cwd.map(Into::into);
|
||||
rc.extras.emulate_terminal = c.option("EXECUTE_IN_TERMINAL") != Some("false");
|
||||
self.envs(c, &mut rc);
|
||||
Ok(rc)
|
||||
}
|
||||
}
|
||||
|
||||
/// Which quotes group words in a parameters field.
|
||||
#[derive(Clone, Copy)]
|
||||
enum Quotes {
|
||||
Double,
|
||||
Single,
|
||||
}
|
||||
|
||||
/// IDEA's `ParametersListUtil.parse`: words split at whitespace, grouped by quotes (dropped);
|
||||
/// a backslash escapes only a quote, so Windows paths (`C:\p\out`) keep theirs.
|
||||
fn parse_params(line: &str, quotes: Quotes) -> Vec<String> {
|
||||
let is_quote = |c: char| c == '"' || (matches!(quotes, Quotes::Single) && c == '\'');
|
||||
let mut out = Vec::new();
|
||||
let mut token = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut escaped = false;
|
||||
let mut non_empty = false;
|
||||
let mut chars = line.trim().chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if quote.map_or(is_quote(c), |q| q == c) {
|
||||
if !escaped {
|
||||
quote = if quote.is_some() { None } else { Some(c) };
|
||||
non_empty = true;
|
||||
continue;
|
||||
}
|
||||
escaped = false;
|
||||
} else if c.is_whitespace() && quote.is_none() {
|
||||
if !token.is_empty() || non_empty {
|
||||
out.push(std::mem::take(&mut token));
|
||||
non_empty = false;
|
||||
}
|
||||
continue;
|
||||
} else if c == '\\'
|
||||
&& let Some(&next) = chars.peek()
|
||||
&& quote.map_or(is_quote(next), |q| q == next)
|
||||
{
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
token.push(c);
|
||||
}
|
||||
if !token.is_empty() || non_empty {
|
||||
out.push(token);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A relative path as a program: `run.sh` → `./run.sh` (`../x` and rooted paths stay; `/opt/x`
|
||||
/// has no drive on Windows but isn't relative either).
|
||||
fn dot_slash(path: String) -> String {
|
||||
if Path::new(&path).has_root() || path.starts_with("./") || path.starts_with("../") {
|
||||
path
|
||||
} else {
|
||||
format!("./{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// How IDEA joins a configuration's `.env` files (`File.pathSeparator`).
|
||||
const PATH_SEPARATOR: char = if cfg!(windows) { ';' } else { ':' };
|
||||
|
||||
/// The `ENV_FILES` paths, joined with `separator` (intellij-community
|
||||
/// `AbstractPythonRunConfiguration.java:303-304`, `:337`).
|
||||
fn split_env_files(value: &str, separator: char) -> impl Iterator<Item = &str> {
|
||||
value.split(separator).filter(|f| !f.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Keywords without the quotes around them (`"not slow"` → `not slow`).
|
||||
fn strip_quotes(s: &str) -> &str {
|
||||
let s = s.trim();
|
||||
for q in ['"', '\''] {
|
||||
if let Some(inner) = s.strip_prefix(q).and_then(|r| r.strip_suffix(q)) {
|
||||
return inner;
|
||||
}
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Modules in `.idea/modules.xml` (0 without the file).
|
||||
fn module_count(root: &Path) -> usize {
|
||||
let Some(doc) = std::fs::read_to_string(root.join(".idea/modules.xml")).ok().and_then(|t| parse(&t).ok()) else {
|
||||
return 0;
|
||||
};
|
||||
let components = std::iter::once(&doc).chain(doc.children.iter());
|
||||
components
|
||||
.filter(|c| c.name == "component" && c.attr("name") == Some("ProjectModuleManager"))
|
||||
.flat_map(|c| c.children_named("modules"))
|
||||
.map(|m| m.children_named("module").count())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// `--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 {
|
||||
let Some(stem) = value.strip_suffix(".py") else { return value.to_owned() };
|
||||
let file = normalize(&root.join(stem));
|
||||
let rel = file.strip_prefix(dir).map(Path::to_path_buf).unwrap_or_else(|_| PathBuf::from(stem));
|
||||
rel.components().map(|c| c.as_os_str().to_string_lossy().into_owned()).collect::<Vec<_>>().join(".")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_elements_attributes_and_escapes() {
|
||||
let doc = parse(
|
||||
"<?xml version=\"1.0\"?>\n<project><component name=\"RunManager\"><configuration name=\"a & b\">\
|
||||
<option name=\"SCRIPT_TEXT\" value=\"echo 1 echo 2\" /></configuration></component></project>",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(doc.name, "project");
|
||||
let c = &doc.children[0].children[0];
|
||||
assert_eq!(c.attr("name"), Some("a & b"));
|
||||
assert_eq!(c.option("SCRIPT_TEXT"), Some("echo 1\necho 2"));
|
||||
assert!(parse("<a><b></a>").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_files_split_on_the_path_separator() {
|
||||
assert_eq!(
|
||||
split_env_files("$PROJECT_DIR$/.env:$PROJECT_DIR$/.env.local", ':').collect::<Vec<_>>(),
|
||||
["$PROJECT_DIR$/.env", "$PROJECT_DIR$/.env.local"]
|
||||
);
|
||||
assert_eq!(
|
||||
split_env_files(r"C:\p\.env;C:\p\.env.local", ';').collect::<Vec<_>>(),
|
||||
[r"C:\p\.env", r"C:\p\.env.local"]
|
||||
);
|
||||
assert_eq!(split_env_files("", ':').count(), 0);
|
||||
assert_eq!(PATH_SEPARATOR, if cfg!(windows) { ';' } else { ':' });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parameters_split_like_idea() {
|
||||
let p = |s: &str| parse_params(s, Quotes::Double);
|
||||
assert_eq!(p(r"--out C:\p\out \\srv\share"), [r"--out", r"C:\p\out", r"\\srv\share"]);
|
||||
assert_eq!(p(r#" a "b c" "" d\"e "f\"g" "#), ["a", "b c", "", r#"d"e"#, r#"f"g"#]);
|
||||
assert_eq!(p("'a b'"), ["'a", "b'"]);
|
||||
assert_eq!(parse_params("-k 'a b' \"c d\"", Quotes::Single), ["-k", "a b", "c d"]);
|
||||
assert!(p(" ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_and_settings_modules() {
|
||||
let cx =
|
||||
Cx { root: Path::new("/p"), home: Some("/home/me".into()), modules: 1, machine_values: Cell::new(false) };
|
||||
assert_eq!(cx.expand("$PROJECT_DIR$/src $5 a$b").unwrap(), "/p/src $5 a$b");
|
||||
assert_eq!(cx.expand_with("cd $PROJECT_DIR$ && $PATH$", Unknown::Keep).unwrap(), "cd /p && $PATH$");
|
||||
assert!(!cx.machine_values.get());
|
||||
assert_eq!(dot_slash("run.sh".into()), "./run.sh");
|
||||
assert_eq!(dot_slash("../run.sh".into()), "../run.sh");
|
||||
assert_eq!(dot_slash("/opt/run.sh".into()), "/opt/run.sh");
|
||||
assert_eq!(cx.expand("$USER_HOME$/.venvs").unwrap(), "/home/me/.venvs");
|
||||
assert!(cx.machine_values.get());
|
||||
assert_eq!(
|
||||
cx.expand("$MAVEN_REPOSITORY$/x"),
|
||||
Err(SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() })
|
||||
);
|
||||
assert_eq!(
|
||||
settings_module("src/mysite/settings/dev.py", Path::new("/p"), Path::new("/p/src")),
|
||||
"mysite.settings.dev"
|
||||
);
|
||||
assert_eq!(settings_module("mysite.settings.dev", Path::new("/p"), Path::new("/p")), "mysite.settings.dev");
|
||||
assert_eq!(json_string("\"tests\""), "tests");
|
||||
let several = Cx { modules: 2, ..cx };
|
||||
assert_eq!(
|
||||
several.expand("$MODULE_DIR$/x"),
|
||||
Err(SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() })
|
||||
);
|
||||
assert_eq!(several.expand("$PROJECT_DIR$/x").unwrap(), "/p/x");
|
||||
assert_eq!(strip_quotes("\"not slow\""), "not slow");
|
||||
assert_eq!(strip_quotes("'a or b'"), "a or b");
|
||||
assert_eq!(strip_quotes("plain"), "plain");
|
||||
assert_eq!(regex_escape("adds 1+1 (sum)"), r"adds 1\+1 \(sum\)");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
//! Run configurations imported on request from another IDE's project files
|
||||
//! (`.vscode/launch.json`). Each source turns its entries into [`Entry`]s; [`finish`]
|
||||
//! gives them ids, skips names that already exist (so importing again adds nothing
|
||||
//! twice) and resolves compound members by name.
|
||||
//! (`.vscode/launch.json`, JetBrains `.run/*.run.xml` and `.idea/workspace.xml`). Each source
|
||||
//! turns its entries into [`Entry`]s; [`finish`] gives them ids, skips names that already exist
|
||||
//! (so importing again adds nothing twice) and resolves compound members by name.
|
||||
|
||||
mod jetbrains;
|
||||
mod vscode;
|
||||
|
||||
use crate::RunConfig;
|
||||
@@ -12,21 +13,31 @@ use specta::Type;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// A project file run configurations can be imported from.
|
||||
/// Project files run configurations can be imported from.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RunImportSource {
|
||||
/// VS Code's `.vscode/launch.json`.
|
||||
Vscode,
|
||||
/// JetBrains IDEs' `.run/*.run.xml`, `.idea/runConfigurations/*.xml` and `.idea/workspace.xml`.
|
||||
Jetbrains,
|
||||
}
|
||||
|
||||
impl RunImportSource {
|
||||
pub const ALL: [Self; 1] = [Self::Vscode];
|
||||
pub const ALL: [Self; 2] = [Self::Vscode, Self::Jetbrains];
|
||||
|
||||
/// The file read, relative to the project root.
|
||||
/// The files read, relative to the project root, as the summary names them.
|
||||
pub fn file(self) -> &'static str {
|
||||
match self {
|
||||
Self::Vscode => ".vscode/launch.json",
|
||||
Self::Jetbrains => ".run, .idea",
|
||||
}
|
||||
}
|
||||
|
||||
fn is_present(self, root: &Path) -> bool {
|
||||
match self {
|
||||
Self::Vscode => root.join(self.file()).is_file(),
|
||||
Self::Jetbrains => !jetbrains::files(root).is_empty(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -62,7 +73,7 @@ pub struct SkippedRunConfig {
|
||||
pub enum SkipReason {
|
||||
/// A configuration with this name already exists.
|
||||
NameTaken,
|
||||
/// The debugger type (`type`) has no equivalent here, e.g. `cppdbg`.
|
||||
/// The configuration type has no equivalent here: a VS Code debugger (`cppdbg`), a JetBrains type (`Application`).
|
||||
UnsupportedType {
|
||||
#[serde(rename = "type")]
|
||||
type_: String,
|
||||
@@ -75,6 +86,14 @@ pub enum SkipReason {
|
||||
Variable { variable: String },
|
||||
/// No program, module or command.
|
||||
NothingToRun,
|
||||
/// A JetBrains path variable with no value here (`$MAVEN_REPOSITORY$`, user-defined ones).
|
||||
PathVariable { variable: String },
|
||||
/// `$MODULE_DIR$` in a project with several modules: which module's directory is unknown.
|
||||
SeveralModules { variable: String },
|
||||
/// A file the configuration needs isn't in the project (Django's `manage.py`).
|
||||
MissingFile { file: String },
|
||||
/// A project file that can't be read, or isn't XML; `name` is the file.
|
||||
InvalidFile { detail: String },
|
||||
/// A compound member that is neither imported nor an existing configuration.
|
||||
MissingMember { member: String },
|
||||
/// Not an object with a name.
|
||||
@@ -104,22 +123,19 @@ pub fn import(
|
||||
env: &BTreeMap<String, String>,
|
||||
) -> IdeResult<RunImport> {
|
||||
let file = source.file();
|
||||
let text = match std::fs::read_to_string(root.join(file)) {
|
||||
Ok(t) => t,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Err(IdeError::not_found(format!("{file} not found")));
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if !source.is_present(root) {
|
||||
return Err(IdeError::not_found(format!("{file} not found")));
|
||||
}
|
||||
let entries = match source {
|
||||
RunImportSource::Vscode => vscode::entries(&text, root, env)?,
|
||||
RunImportSource::Vscode => vscode::entries(&std::fs::read_to_string(root.join(file))?, root, env)?,
|
||||
RunImportSource::Jetbrains => jetbrains::entries(root, env)?,
|
||||
};
|
||||
Ok(finish(source, entries, existing))
|
||||
}
|
||||
|
||||
/// The sources whose file exists under `root`.
|
||||
/// The sources with files under `root`.
|
||||
pub fn available(root: &Path) -> Vec<RunImportSource> {
|
||||
RunImportSource::ALL.into_iter().filter(|s| root.join(s.file()).is_file()).collect()
|
||||
RunImportSource::ALL.into_iter().filter(|s| s.is_present(root)).collect()
|
||||
}
|
||||
|
||||
fn unique_id(name: &str, ids: &mut HashSet<String>) -> String {
|
||||
@@ -189,6 +205,107 @@ pub(crate) fn finish(source: RunImportSource, entries: Vec<Entry>, existing: &[R
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- shared by the sources
|
||||
|
||||
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
|
||||
pub(crate) fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
|
||||
match env.get(name) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
|
||||
pub(crate) fn command_name(cmd: &str) -> &str {
|
||||
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
|
||||
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
|
||||
}
|
||||
|
||||
pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
|
||||
RunConfig {
|
||||
id: String::new(),
|
||||
name: name.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
command,
|
||||
args,
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: debug.map(str::to_owned),
|
||||
allow_parallel: false,
|
||||
extras: crate::RunExtras::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
|
||||
/// (like `parseArgs` in `src/platform/commandLine.ts`).
|
||||
pub(crate) fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
|
||||
cur.extend(chars.next());
|
||||
}
|
||||
Some(_) => cur.push(c),
|
||||
None if c == '\'' || c == '"' => {
|
||||
quote = Some(c);
|
||||
has = true;
|
||||
}
|
||||
None if c == '\\' && chars.peek().is_some() => {
|
||||
cur.extend(chars.next());
|
||||
has = true;
|
||||
}
|
||||
None if c.is_whitespace() => {
|
||||
if has || !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
has = false;
|
||||
}
|
||||
None => cur.push(c),
|
||||
}
|
||||
}
|
||||
if has || !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An absolute path inside the project, written relative to `dir` (the run's working
|
||||
/// directory, where the process resolves it); anything else as it is.
|
||||
pub(crate) fn path_from(root: &Path, value: String, dir: &Path) -> String {
|
||||
let path = Path::new(&value);
|
||||
if !path.is_absolute() {
|
||||
return value;
|
||||
}
|
||||
let path = normalize(path);
|
||||
if path.starts_with(root) && dir.starts_with(root) { relative_to(&path, dir).unwrap_or(value) } else { value }
|
||||
}
|
||||
|
||||
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
|
||||
pub(crate) fn project_python(root: &Path, dir: &Path) -> (String, Vec<String>) {
|
||||
let (exe, lead) = crate::detect::project_python(root);
|
||||
let exe = if exe.contains('/') { path_from(root, root.join(&exe).to_string_lossy().into(), dir) } else { exe };
|
||||
(exe, lead)
|
||||
}
|
||||
|
||||
/// The working directory `cwd` (absolute) and the configuration's `cwd` (relative to the root, `None` for the root).
|
||||
pub(crate) fn working_dir(root: &Path, cwd: &str) -> (PathBuf, Option<PathBuf>) {
|
||||
let abs = normalize(&root.join(cwd));
|
||||
let rel = match abs.strip_prefix(root) {
|
||||
Ok(r) if r.as_os_str().is_empty() => None,
|
||||
Ok(r) => Some(PathBuf::from(relative_to(&abs, root).unwrap_or_else(|| r.to_string_lossy().into()))),
|
||||
Err(_) => Some(abs.clone()),
|
||||
};
|
||||
(abs, rel)
|
||||
}
|
||||
|
||||
/// `path` as written relative to `base` (both absolute, without `..`), with `/` separators;
|
||||
/// `None` when they don't share a prefix (another drive).
|
||||
pub(crate) fn relative_to(path: &Path, base: &Path) -> Option<String> {
|
||||
|
||||
@@ -6,8 +6,11 @@
|
||||
//! an entry using one that depends on the moment VS Code launches it (`${file}`,
|
||||
//! `${input:…}`, `${command:…}`) is skipped.
|
||||
|
||||
use super::{Entry, Found, SkipReason, normalize, relative_to};
|
||||
use crate::{RunConfig, RunExtras};
|
||||
use super::{
|
||||
Entry, Found, SkipReason, command_name, config, env_var, normalize, path_from, project_python, split_args,
|
||||
working_dir,
|
||||
};
|
||||
use crate::RunConfig;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde_json::{Map, Value};
|
||||
use std::cell::Cell;
|
||||
@@ -87,15 +90,6 @@ fn name_of(v: &Value) -> String {
|
||||
v.get("name").and_then(Value::as_str).unwrap_or_default().trim().to_owned()
|
||||
}
|
||||
|
||||
/// The variable `name` of `env`; Windows names ignore case (`${env:PATH}` finds `Path`).
|
||||
fn env_var(env: &BTreeMap<String, String>, name: &str, windows: bool) -> Option<String> {
|
||||
match env.get(name) {
|
||||
Some(v) => Some(v.clone()),
|
||||
None if windows => env.iter().find(|(k, _)| k.eq_ignore_ascii_case(name)).map(|(_, v)| v.clone()),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// String form of a scalar (`"args": [8000]`, `"env": { "DEBUG": true }`).
|
||||
fn scalar(v: &Value) -> Option<String> {
|
||||
match v {
|
||||
@@ -106,29 +100,6 @@ fn scalar(v: &Value) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The program's file name without a Windows extension (`npm.cmd` → `npm`).
|
||||
fn command_name(cmd: &str) -> &str {
|
||||
let name = cmd.rsplit(['/', '\\']).next().unwrap_or(cmd);
|
||||
name.strip_suffix(".cmd").or_else(|| name.strip_suffix(".exe")).unwrap_or(name)
|
||||
}
|
||||
|
||||
fn config(name: &str, kind: &str, command: String, args: Vec<String>, debug: Option<&str>) -> RunConfig {
|
||||
RunConfig {
|
||||
id: String::new(),
|
||||
name: name.to_owned(),
|
||||
kind: kind.to_owned(),
|
||||
command,
|
||||
args,
|
||||
cwd: None,
|
||||
env: BTreeMap::new(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: debug.map(str::to_owned),
|
||||
allow_parallel: false,
|
||||
extras: RunExtras::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `npm run dev`-style words: an npm configuration's `(command, args)`, `run-script` read as `run`.
|
||||
fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
|
||||
let (exe, rest) = words.split_first()?;
|
||||
@@ -142,44 +113,6 @@ fn npm_line(words: &[String]) -> Option<(String, Vec<String>)> {
|
||||
NPM_COMMANDS.contains(&args.first()?.as_str()).then(|| (exe.clone(), args))
|
||||
}
|
||||
|
||||
/// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes
|
||||
/// (like `parseArgs` in `src/platform/commandLine.ts`).
|
||||
fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
let mut quote: Option<char> = None;
|
||||
let mut chars = line.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
match quote {
|
||||
Some(q) if c == q => quote = None,
|
||||
Some('"') if c == '\\' && chars.peek().is_some_and(|n| "\"\\$`".contains(*n)) => {
|
||||
cur.extend(chars.next());
|
||||
}
|
||||
Some(_) => cur.push(c),
|
||||
None if c == '\'' || c == '"' => {
|
||||
quote = Some(c);
|
||||
has = true;
|
||||
}
|
||||
None if c == '\\' && chars.peek().is_some() => {
|
||||
cur.extend(chars.next());
|
||||
has = true;
|
||||
}
|
||||
None if c.is_whitespace() => {
|
||||
if has || !cur.is_empty() {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
}
|
||||
has = false;
|
||||
}
|
||||
None => cur.push(c),
|
||||
}
|
||||
}
|
||||
if has || !cur.is_empty() {
|
||||
out.push(cur);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl Cx<'_> {
|
||||
/// Replaces the variables in `s`.
|
||||
fn expand(&self, s: &str) -> Res<String> {
|
||||
@@ -235,16 +168,7 @@ impl Cx<'_> {
|
||||
/// An absolute path inside the project, written relative to `dir` (the run's working
|
||||
/// directory, where the process resolves it); anything else as it is.
|
||||
fn path_from(&self, value: String, dir: &Path) -> String {
|
||||
let path = Path::new(&value);
|
||||
if !path.is_absolute() {
|
||||
return value;
|
||||
}
|
||||
let path = normalize(path);
|
||||
if path.starts_with(self.root) && dir.starts_with(self.root) {
|
||||
relative_to(&path, dir).unwrap_or(value)
|
||||
} else {
|
||||
value
|
||||
}
|
||||
path_from(self.root, value, dir)
|
||||
}
|
||||
|
||||
/// The working directory (absolute) and the configuration's `cwd` (relative to the root).
|
||||
@@ -252,13 +176,7 @@ impl Cx<'_> {
|
||||
let Some(cwd) = self.string(obj, "cwd")? else {
|
||||
return Ok((self.root.to_path_buf(), None));
|
||||
};
|
||||
let abs = normalize(&self.root.join(cwd));
|
||||
let rel = match abs.strip_prefix(self.root) {
|
||||
Ok(r) if r.as_os_str().is_empty() => None,
|
||||
Ok(r) => Some(PathBuf::from(relative_to(&abs, self.root).unwrap_or_else(|| r.to_string_lossy().into()))),
|
||||
Err(_) => Some(abs.clone()),
|
||||
};
|
||||
Ok((abs, rel))
|
||||
Ok(working_dir(self.root, &cwd))
|
||||
}
|
||||
|
||||
/// `env` (a variable set to itself is inherited anyway) and `envFile`.
|
||||
@@ -341,14 +259,14 @@ impl Cx<'_> {
|
||||
Some((exe, opts)) => {
|
||||
let opts = opts.iter().map(|o| self.expand(o)).collect::<Res<Vec<_>>>()?;
|
||||
if exe.trim().is_empty() || selected(exe) {
|
||||
let (exe, mut lead) = self.project_python(&dir);
|
||||
let (exe, mut lead) = project_python(self.root, &dir);
|
||||
lead.extend(opts);
|
||||
(exe, lead)
|
||||
} else {
|
||||
(self.path_from(self.expand(exe)?, &dir), opts)
|
||||
}
|
||||
}
|
||||
None => self.project_python(&dir),
|
||||
None => project_python(self.root, &dir),
|
||||
};
|
||||
args.extend(self.words(obj, "pythonArgs")?);
|
||||
let rest = self.words(obj, "args")?;
|
||||
@@ -375,14 +293,6 @@ impl Cx<'_> {
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
/// The project's interpreter (see [`crate::detect::project_python`]), relative to `dir`.
|
||||
fn project_python(&self, dir: &Path) -> (String, Vec<String>) {
|
||||
let (exe, lead) = crate::detect::project_python(self.root);
|
||||
let exe =
|
||||
if exe.contains('/') { self.path_from(self.root.join(&exe).to_string_lossy().into(), dir) } else { exe };
|
||||
(exe, lead)
|
||||
}
|
||||
|
||||
fn python_attach(&self, name: &str, obj: &Map<String, Value>) -> Res<RunConfig> {
|
||||
if obj.contains_key("processId") || obj.contains_key("listen") {
|
||||
return Err(SkipReason::UnsupportedAttach);
|
||||
|
||||
@@ -203,6 +203,94 @@ pub fn explode(war: &Path, destination: &Path, resources_only: bool) -> IdeResul
|
||||
remove_stale(destination, destination, &expected, resources_only)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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
|
||||
/// archive in `WEB-INF/lib` would be stale) — rebuild the WAR instead.
|
||||
pub fn sync_outputs(base: &Path, module: &Path, class_paths: &[PathBuf], resources_only: bool) -> IdeResult<bool> {
|
||||
let dest = base.join("deployment");
|
||||
let manifest = base.join("synced-files.json");
|
||||
let since = [base.join("artifact-path.txt"), manifest.clone()]
|
||||
.iter()
|
||||
.filter_map(|p| fs::metadata(p).and_then(|m| m.modified()).ok())
|
||||
.max();
|
||||
let Some(since) = since else { return Ok(false) };
|
||||
if !dest.join("WEB-INF").is_dir() {
|
||||
return Ok(false);
|
||||
}
|
||||
let (own, others): (Vec<&PathBuf>, Vec<&PathBuf>) =
|
||||
class_paths.iter().filter(|p| p.is_dir()).partition(|p| p.starts_with(module));
|
||||
if own.is_empty() || others.iter().any(|d| modified_since(d, since)) {
|
||||
return Ok(false);
|
||||
}
|
||||
let previous: Vec<String> =
|
||||
fs::read(&manifest).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default();
|
||||
let mut current = std::collections::BTreeSet::new();
|
||||
let classes = dest.join("WEB-INF/classes");
|
||||
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 rel in previous.iter().filter(|p| !current.contains(*p)) {
|
||||
let path = dest.join(rel);
|
||||
if path.is_file() {
|
||||
fs::remove_file(path)?;
|
||||
}
|
||||
}
|
||||
fs::write(manifest, serde_json::to_vec(¤t).map_err(invalid)?)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
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() {
|
||||
Ok(t) if t.is_dir() => modified_since(&e.path(), since),
|
||||
Ok(t) if t.is_file() => e.metadata().and_then(|m| m.modified()).is_ok_and(|m| m > since),
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
|
||||
/// Copies changed files of `from` (below `root`) to `to`; unchanged files keep their timestamps,
|
||||
/// so Tomcat only reloads when a class really changed. Records each source file as `prefix/rel`.
|
||||
fn sync_tree(
|
||||
root: &Path,
|
||||
from: &Path,
|
||||
to: &Path,
|
||||
prefix: &Path,
|
||||
resources_only: bool,
|
||||
seen: &mut std::collections::BTreeSet<String>,
|
||||
) -> IdeResult<()> {
|
||||
for entry in fs::read_dir(from)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
let kind = entry.file_type()?;
|
||||
if kind.is_dir() {
|
||||
sync_tree(root, &path, to, prefix, resources_only, seen)?;
|
||||
continue;
|
||||
}
|
||||
if !kind.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = path.strip_prefix(root).map_err(invalid)?;
|
||||
seen.insert(prefix.join(rel).to_string_lossy().replace('\\', "/"));
|
||||
if resources_only && rel.extension().is_some_and(|e| e == "class") {
|
||||
continue;
|
||||
}
|
||||
let target = to.join(rel);
|
||||
let bytes = fs::read(&path)?;
|
||||
if fs::read(&target).ok().as_deref() != Some(&bytes) {
|
||||
fs::create_dir_all(target.parent().unwrap_or(to))?;
|
||||
fs::write(target, bytes)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only: bool) -> IdeResult<PathBuf> {
|
||||
let p = &config.extras.params;
|
||||
match p.get("deployment").map(String::as_str).unwrap_or("artifact") {
|
||||
@@ -439,6 +527,54 @@ mod tests {
|
||||
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
|
||||
}
|
||||
#[test]
|
||||
fn updates_the_exploded_deployment_from_module_outputs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let (base, module) = (root.join("base"), root.join("web"));
|
||||
let (dest, out, dep) =
|
||||
(base.join("deployment"), module.join("target/classes"), root.join("core/target/classes"));
|
||||
let webapp = module.join("src/main/webapp");
|
||||
for dir in [dest.join("WEB-INF/classes/a"), out.join("a"), webapp.clone(), dep.clone()] {
|
||||
fs::create_dir_all(dir).unwrap();
|
||||
}
|
||||
for (path, text) in [
|
||||
(dest.join("WEB-INF/classes/a/Same.class"), "same"),
|
||||
(dest.join("WEB-INF/classes/a/App.class"), "old"),
|
||||
(dest.join("index.jsp"), "old"),
|
||||
(out.join("a/Same.class"), "same"),
|
||||
(out.join("a/App.class"), "new"),
|
||||
(out.join("app.properties"), "new"),
|
||||
(webapp.join("index.jsp"), "new"),
|
||||
] {
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
let paths = [out.clone(), dep.clone(), root.join("lib.jar")];
|
||||
// Not deployed from the artifact yet.
|
||||
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
|
||||
fs::write(base.join("artifact-path.txt"), "web.war").unwrap();
|
||||
let same = || fs::metadata(dest.join("WEB-INF/classes/a/Same.class")).unwrap().modified().unwrap();
|
||||
let before = same();
|
||||
|
||||
assert!(sync_outputs(&base, &module, &paths, true).unwrap());
|
||||
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "old");
|
||||
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/app.properties")).unwrap(), "new");
|
||||
assert_eq!(fs::read_to_string(dest.join("index.jsp")).unwrap(), "new");
|
||||
|
||||
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
|
||||
assert_eq!(fs::read_to_string(dest.join("WEB-INF/classes/a/App.class")).unwrap(), "new");
|
||||
assert_eq!(same(), before);
|
||||
|
||||
fs::remove_file(out.join("app.properties")).unwrap();
|
||||
assert!(sync_outputs(&base, &module, &paths, false).unwrap());
|
||||
assert!(!dest.join("WEB-INF/classes/app.properties").exists());
|
||||
assert!(dest.join("WEB-INF/classes/a/Same.class").exists());
|
||||
|
||||
// A dependency module compiled since: its archive in WEB-INF/lib is stale.
|
||||
let later = std::time::SystemTime::now() + std::time::Duration::from_secs(60);
|
||||
fs::File::create(dep.join("Core.class")).unwrap().set_modified(later).unwrap();
|
||||
assert!(!sync_outputs(&base, &module, &paths, false).unwrap());
|
||||
}
|
||||
#[test]
|
||||
fn deployment_errors_identify_the_checked_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("cache");
|
||||
|
||||
@@ -0,0 +1,704 @@
|
||||
//! JetBrains run configuration import. The fixtures are synthetic: they follow the structure
|
||||
//! PyCharm and WebStorm write (`.run/*.run.xml`, `.idea/workspace.xml`), with invented names and values.
|
||||
|
||||
use ide_run::RunConfig;
|
||||
use ide_run::import::{RunImport, RunImportSource, SkipReason, available, import};
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// IDEA joins `ENV_FILES` with `File.pathSeparator`.
|
||||
const SEP: char = if cfg!(windows) { ';' } else { ':' };
|
||||
const VENV_PYTHON: &str = if cfg!(windows) { ".venv/Scripts/python.exe" } else { ".venv/bin/python" };
|
||||
|
||||
fn env() -> BTreeMap<String, String> {
|
||||
BTreeMap::from([("HOME".into(), "/home/me".into()), ("USERPROFILE".into(), "/home/me".into())])
|
||||
}
|
||||
|
||||
fn write(root: &Path, rel: &str, text: &str) {
|
||||
let path = root.join(rel);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn run_file(name: &str, body: &str) -> String {
|
||||
format!(
|
||||
"<component name=\"ProjectRunConfigurationManager\">\n {}\n</component>\n",
|
||||
body.replace("name=\"NAME\"", &format!("name=\"{name}\""))
|
||||
)
|
||||
}
|
||||
|
||||
fn workspace(configs: &str) -> String {
|
||||
format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<project version=\"4\">\n <component name=\"ChangeListManager\" />\n \
|
||||
<component name=\"RunManager\" selected=\"Python.App\">\n{configs}\n <list>\n <item itemvalue=\"Python.App\" />\n \
|
||||
</list>\n </component>\n</project>\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn import_from(root: &Path, existing: &[RunConfig]) -> RunImport {
|
||||
import(root, RunImportSource::Jetbrains, existing, &env()).unwrap()
|
||||
}
|
||||
|
||||
fn get<'a>(r: &'a RunImport, name: &str) -> &'a RunConfig {
|
||||
&r.imported.iter().find(|c| c.config.name == name).unwrap_or_else(|| panic!("{name} not imported: {r:#?}")).config
|
||||
}
|
||||
|
||||
fn ignored<'a>(r: &'a RunImport, name: &str) -> &'a [String] {
|
||||
&r.imported.iter().find(|c| c.config.name == name).unwrap().ignored
|
||||
}
|
||||
|
||||
fn reason<'a>(r: &'a RunImport, name: &str) -> &'a SkipReason {
|
||||
&r.skipped.iter().find(|s| s.name == name).unwrap_or_else(|| panic!("{name} not skipped: {r:#?}")).reason
|
||||
}
|
||||
|
||||
fn s(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|a| (*a).to_owned()).collect()
|
||||
}
|
||||
|
||||
fn cwd(c: &RunConfig) -> Option<PathBuf> {
|
||||
c.cwd.as_ref().map(|p| p.as_ref().to_path_buf())
|
||||
}
|
||||
|
||||
/// A Django project in `src/` with a root virtualenv.
|
||||
fn django_project() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "src/manage.py", "");
|
||||
write(dir.path(), VENV_PYTHON, "");
|
||||
dir
|
||||
}
|
||||
|
||||
const PY_COMMON: &str = r#"<module name="shop" />
|
||||
<option name="ENV_FILES" value="" />
|
||||
<option name="INTERPRETER_OPTIONS" value="" />
|
||||
<option name="PARENT_ENVS" value="true" />
|
||||
<option name="SDK_HOME" value="" />
|
||||
<option name="IS_MODULE_SDK" value="true" />
|
||||
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||
<option name="ADD_SOURCE_ROOTS" value="true" />"#;
|
||||
|
||||
#[test]
|
||||
fn django_server_and_tests_from_shared_run_files() {
|
||||
let dir = django_project();
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Server.run.xml",
|
||||
&run_file(
|
||||
"Server",
|
||||
&format!(
|
||||
r#"<configuration default="false" name="NAME" type="Python.DjangoServer" factoryName="Django server">
|
||||
{PY_COMMON}
|
||||
<envs>
|
||||
<env name="PYTHONUNBUFFERED" value="1" />
|
||||
<env name="DJANGO_SETTINGS_MODULE" value="shop.settings.dev" />
|
||||
</envs>
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
|
||||
<option name="launchJavascriptDebuger" value="false" />
|
||||
<option name="port" value="8001" />
|
||||
<option name="host" value="0.0.0.0" />
|
||||
<option name="additionalOptions" value="--insecure" />
|
||||
<option name="browserUrl" value="" />
|
||||
<option name="runTestServer" value="false" />
|
||||
<option name="runNoReload" value="true" />
|
||||
<option name="useCustomRunCommand" value="true" />
|
||||
<option name="customRunCommand" value="runserver_plus" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Tests.run.xml",
|
||||
&run_file(
|
||||
"Tests",
|
||||
&format!(
|
||||
r#"<configuration default="false" name="NAME" type="DjangoTestsConfigurationType">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
|
||||
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
|
||||
<option name="TARGET" value="orders.tests catalog" />
|
||||
<option name="SETTINGS_FILE" value="$PROJECT_DIR$/src/shop/settings/test.py" />
|
||||
<option name="CUSTOM_SETTINGS" value="true" />
|
||||
<option name="USE_OPTIONS" value="false" />
|
||||
<option name="OPTIONS" value="--keepdb" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
assert!(r.skipped.is_empty(), "{r:#?}");
|
||||
assert_eq!(r.file, ".run, .idea");
|
||||
|
||||
let server = get(&r, "Server");
|
||||
assert_eq!(server.kind, "python");
|
||||
assert_eq!(server.command, format!("../{VENV_PYTHON}"));
|
||||
assert_eq!(server.args, s(&["manage.py", "runserver_plus", "0.0.0.0:8001", "--noreload", "--insecure"]));
|
||||
assert_eq!(cwd(server), Some(PathBuf::from("src")));
|
||||
assert_eq!(server.env["DJANGO_SETTINGS_MODULE"], "shop.settings.dev");
|
||||
assert_eq!(server.debug_adapter.as_deref(), Some("debugpy"));
|
||||
assert!(server.extras.include_system_env && !server.extras.local);
|
||||
|
||||
let tests = get(&r, "Tests");
|
||||
assert_eq!(tests.kind, "django-test");
|
||||
assert!(tests.is_test);
|
||||
// OPTIONS count only with USE_OPTIONS, as in PyCharm; the settings file becomes its module.
|
||||
assert_eq!(tests.args, s(&["manage.py", "test", "orders.tests", "catalog", "--settings", "shop.settings.test"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_run_manager_skips_templates_and_temporary_configurations() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "web/package.json", r#"{ "name": "web", "scripts": { "dev": "vite", "test": "vitest" } }"#);
|
||||
write(dir.path(), "web/pnpm-lock.yaml", "");
|
||||
write(dir.path(), "tests/__init__.py", "");
|
||||
write(dir.path(), "tests/test_money.py", "");
|
||||
write(
|
||||
dir.path(),
|
||||
".idea/workspace.xml",
|
||||
&workspace(&format!(
|
||||
r#" <configuration default="true" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="SCRIPT_NAME" value="" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="build" type="js.build_tools.npm" temporary="true" nameIsGenerated="true">
|
||||
<package-json value="$PROJECT_DIR$/web/package.json" />
|
||||
<command value="run" />
|
||||
<scripts><script value="build" /></scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="App" type="PythonConfigurationType" factoryName="Python" folderName="Tools" singleton="false">
|
||||
<module name="shop" />
|
||||
<option name="ENV_FILES" value="$PROJECT_DIR$/.env{SEP}$PROJECT_DIR$/.env.local" />
|
||||
<option name="INTERPRETER_OPTIONS" value="-X dev" />
|
||||
<option name="PARENT_ENVS" value="false" />
|
||||
<envs><env name="LOG_LEVEL" value="debug" /></envs>
|
||||
<option name="SDK_HOME" value="$USER_HOME$/.virtualenvs/shop/bin/python" />
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="IS_MODULE_SDK" value="false" />
|
||||
<option name="RUN_TOOL" value="" />
|
||||
<option name="SCRIPT_NAME" value="shop.tools.sync" />
|
||||
<option name="PARAMETERS" value="--since 2024-01-01 --out $PROJECT_DIR$/out" />
|
||||
<option name="SHOW_COMMAND_LINE" value="false" />
|
||||
<option name="EMULATE_TERMINAL" value="true" />
|
||||
<option name="MODULE_MODE" value="true" />
|
||||
<option name="REDIRECT_INPUT" value="false" />
|
||||
<option name="INPUT_FILE" value="" />
|
||||
<method v="2">
|
||||
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Migrate" run_configuration_type="PythonConfigurationType" />
|
||||
</method>
|
||||
</configuration>
|
||||
<configuration name="Unit" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
<option name="_new_keywords" value=""not slow"" />
|
||||
<option name="_new_parameters" value="""" />
|
||||
<option name="_new_additionalArguments" value=""-x --tb=short"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests/test_orders.py"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Case" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_parameters" value=""eur-2"" />
|
||||
<option name="_new_target" value=""tests.test_money.test_round"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Path params" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_keywords" value=""'slow or fast'"" />
|
||||
<option name="_new_parameters" value=""eur-2"" />
|
||||
<option name="_new_target" value=""$PROJECT_DIR$/tests/test_money.py"" />
|
||||
<option name="_new_targetType" value=""PATH"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Custom" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="_new_keywords" value=""slow"" />
|
||||
<option name="_new_additionalArguments" value=""tests/a.py --maxfail=2"" />
|
||||
<option name="_new_target" value="""" />
|
||||
<option name="_new_targetType" value=""CUSTOM"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Unittests in tests" type="tests" factoryName="Unittests">
|
||||
{PY_COMMON}
|
||||
<option name="_new_target" value=""tests"" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="dev" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/web/package.json" />
|
||||
<command value="run" />
|
||||
<scripts><script value="dev" /><script value="test" /></scripts>
|
||||
<arguments value="--host 0.0.0.0" />
|
||||
<node-interpreter value="/opt/node22/bin/node" />
|
||||
<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" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
)),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
let names: Vec<&str> = r.imported.iter().map(|c| c.config.name.as_str()).collect();
|
||||
assert_eq!(names, ["App", "Unit", "Case", "Path params", "Custom", "dev"]);
|
||||
|
||||
let app = get(&r, "App");
|
||||
// `$USER_HOME$` is this machine's: the configuration goes to run.local.toml.
|
||||
assert!(app.extras.local);
|
||||
assert_eq!(app.command, "/home/me/.virtualenvs/shop/bin/python");
|
||||
assert_eq!(app.args, s(&["-X", "dev", "-m", "shop.tools.sync", "--since", "2024-01-01", "--out", "out"]));
|
||||
assert_eq!(app.extras.env_files, s(&[".env", ".env.local"]));
|
||||
assert!(!app.extras.include_system_env && app.extras.emulate_terminal && app.allow_parallel);
|
||||
assert_eq!(app.extras.folder.as_deref(), Some("Tools"));
|
||||
assert_eq!(ignored(&r, "App"), ["before launch"]);
|
||||
|
||||
let unit = get(&r, "Unit");
|
||||
assert_eq!(unit.kind, "pytest");
|
||||
assert!(unit.is_test);
|
||||
let py = if cfg!(windows) { "python" } else { "python3" };
|
||||
assert_eq!(unit.command, py);
|
||||
assert_eq!(unit.args, s(&["-m", "pytest", "tests/test_orders.py", "-k", "not slow", "-x", "--tb=short"]));
|
||||
// intellij-community PyTest.kt: parameters follow only a Python target; keywords lose their
|
||||
// quotes and are left out for a custom target; additional arguments split like a command line.
|
||||
assert_eq!(get(&r, "Case").args, s(&["-m", "pytest", "tests/test_money.py::test_round[eur-2]"]));
|
||||
assert_eq!(get(&r, "Path params").args, s(&["-m", "pytest", "tests/test_money.py", "-k", "slow or fast"]));
|
||||
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() });
|
||||
|
||||
let dev = get(&r, "dev");
|
||||
assert_eq!((dev.kind.as_str(), dev.command.as_str()), ("npm", "pnpm"));
|
||||
assert_eq!(dev.args, s(&["run", "dev", "--host", "0.0.0.0"]));
|
||||
assert_eq!(cwd(dev), Some(PathBuf::from("web")));
|
||||
assert_eq!(dev.env["VITE_API"], "http://localhost:8000");
|
||||
// The configuration's Node interpreter goes to the npm form, with the fields it shows.
|
||||
let params: Vec<(&str, &str)> = dev.extras.params.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(
|
||||
params,
|
||||
[
|
||||
("arguments", "--host 0.0.0.0"),
|
||||
("command", "run"),
|
||||
("nodeInterpreter", "/opt/node22/bin/node"),
|
||||
("packageDir", "web"),
|
||||
("packageManager", "pnpm"),
|
||||
("script", "dev"),
|
||||
]
|
||||
);
|
||||
assert_eq!(ignored(&r, "dev"), ["scripts"]);
|
||||
// Neither the template nor the temporary `build` is listed as skipped.
|
||||
assert_eq!(r.skipped.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_tests_node_shell_and_compounds() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
".idea/workspace.xml",
|
||||
&workspace(
|
||||
r#" <configuration name="cart total" type="JavaScriptTestRunnerVitest">
|
||||
<node-interpreter value="project" />
|
||||
<vitest-package value="$PROJECT_DIR$/web/node_modules/vitest" />
|
||||
<working-dir value="$PROJECT_DIR$/web" />
|
||||
<vitest-options value="--reporter verbose" />
|
||||
<envs />
|
||||
<scope-kind value="TEST" />
|
||||
<test-file value="$PROJECT_DIR$/web/src/cart.test.ts" />
|
||||
<test-names>
|
||||
<test-name value="cart" />
|
||||
<test-name value="total (with tax)" />
|
||||
</test-names>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="api tests" type="JavaScriptTestRunnerJest">
|
||||
<node-interpreter value="project" />
|
||||
<jest-package value="$PROJECT_DIR$/node_modules/jest" />
|
||||
<working-dir value="$PROJECT_DIR$" />
|
||||
<jest-options value="--runInBand" />
|
||||
<config-file value="$PROJECT_DIR$/jest.config.js" />
|
||||
<envs><env name="NODE_ENV" value="test" /></envs>
|
||||
<scope-kind value="DIRECTORY" />
|
||||
<test-directory value="$PROJECT_DIR$/api/__tests__" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="server.js" type="NodeJSConfigurationType" path-to-js-file="server/index.js" working-dir="$PROJECT_DIR$" node-parameters="--enable-source-maps" application-parameters="--port 3000">
|
||||
<envs><env name="PORT" value="3000" /></envs>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="seed" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="" />
|
||||
<option name="INDEPENDENT_SCRIPT_PATH" value="true" />
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
|
||||
<option name="SCRIPT_OPTIONS" value="--small" />
|
||||
<option name="INDEPENDENT_SCRIPT_WORKING_DIRECTORY" value="true" />
|
||||
<option name="SCRIPT_WORKING_DIRECTORY" value="$PROJECT_DIR$" />
|
||||
<option name="INDEPENDENT_INTERPRETER_PATH" value="true" />
|
||||
<option name="INTERPRETER_PATH" value="/bin/bash" />
|
||||
<option name="INTERPRETER_OPTIONS" value="-e" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="true" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="clean" type="ShConfigurationType">
|
||||
<option name="SCRIPT_TEXT" value="cd $PROJECT_DIR$/web && npm run dev && echo $HOME$PATH $1 $HOME" />
|
||||
<option name="EXECUTE_IN_TERMINAL" value="false" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="false" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Stack" type="CompoundRunConfigurationType">
|
||||
<toRun name="server.js" type="NodeJSConfigurationType" />
|
||||
<toRun name="seed" type="ShConfigurationType" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Broken" type="CompoundRunConfigurationType">
|
||||
<toRun name="Gone" type="NodeJSConfigurationType" />
|
||||
<method v="2" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
|
||||
let vitest = get(&r, "cart total");
|
||||
assert_eq!((vitest.kind.as_str(), vitest.command.as_str()), ("node", "npx"));
|
||||
assert_eq!(
|
||||
vitest.args,
|
||||
s(&["vitest", "run", "--reporter", "verbose", "src/cart.test.ts", "-t", r"^cart total \(with tax\)$"])
|
||||
);
|
||||
assert_eq!(cwd(vitest), Some(PathBuf::from("web")));
|
||||
assert!(vitest.is_test);
|
||||
|
||||
let jest = get(&r, "api tests");
|
||||
assert_eq!(jest.args, s(&["jest", "--config", "jest.config.js", "--runInBand", "api/__tests__"]));
|
||||
assert_eq!(jest.env["NODE_ENV"], "test");
|
||||
|
||||
let node = get(&r, "server.js");
|
||||
assert_eq!((node.kind.as_str(), node.command.as_str()), ("node", "node"));
|
||||
assert_eq!(node.args, s(&["--enable-source-maps", "server/index.js", "--port", "3000"]));
|
||||
assert_eq!(node.debug_adapter.as_deref(), Some("js-debug"));
|
||||
|
||||
let seed = get(&r, "seed");
|
||||
assert_eq!((seed.kind.as_str(), seed.command.as_str()), ("shell", "/bin/bash"));
|
||||
assert_eq!(seed.args, s(&["-e", "scripts/seed.sh", "--small"]));
|
||||
assert_eq!(seed.extras.params["mode"], "file");
|
||||
assert_eq!(seed.extras.params["path"], "scripts/seed.sh");
|
||||
assert!(seed.extras.emulate_terminal);
|
||||
|
||||
// Script text is the shell's own: `$HOME$` there is not a macro.
|
||||
let clean = get(&r, "clean");
|
||||
// IDEA writes `$PROJECT_DIR$` into script text too; the shell's own `$HOME`, `$1`, `$HOME$PATH` stay.
|
||||
let root = dir.path().to_string_lossy();
|
||||
let line = format!("cd {root}/web && npm run dev && echo $HOME$PATH $1 $HOME");
|
||||
assert_eq!(clean.command, line);
|
||||
assert_eq!(clean.extras.params["script"], line);
|
||||
assert_eq!(clean.extras.params["mode"], "text");
|
||||
assert!(!clean.extras.local);
|
||||
|
||||
let stack = get(&r, "Stack");
|
||||
assert_eq!(stack.kind, "compound");
|
||||
assert_eq!(stack.extras.compound, s(&["user:server-js", "user:seed"]));
|
||||
assert_eq!(reason(&r, "Broken"), &SkipReason::MissingMember { member: "Gone".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_files_win_and_reimport_adds_nothing() {
|
||||
let dir = django_project();
|
||||
let tests = |name: &str, target: &str| {
|
||||
run_file(
|
||||
name,
|
||||
&format!(
|
||||
r#"<configuration name="NAME" type="DjangoTestsConfigurationType">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/src" />
|
||||
<option name="TARGET" value="{target}" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
)
|
||||
};
|
||||
write(dir.path(), ".run/Tests.run.xml", &tests("Tests", "shared"));
|
||||
write(dir.path(), ".idea/runConfigurations/Other.xml", &tests("Other", "other"));
|
||||
let mine = tests("Tests", "mine").replace("ProjectRunConfigurationManager", "RunManager");
|
||||
write(dir.path(), ".idea/workspace.xml", &format!("<project version=\"4\">{mine}</project>"));
|
||||
write(dir.path(), ".run/Broken.run.xml", "<component name=\"ProjectRunConfigurationManager\"><configuration");
|
||||
|
||||
let r = import_from(dir.path(), &[]);
|
||||
assert_eq!(get(&r, "Tests").args.last().map(String::as_str), Some("shared"));
|
||||
assert_eq!(get(&r, "Other").args.last().map(String::as_str), Some("other"));
|
||||
assert_eq!(reason(&r, "Tests"), &SkipReason::NameTaken);
|
||||
// The parser's message says where the file breaks.
|
||||
assert!(matches!(reason(&r, ".run/Broken.run.xml"), SkipReason::InvalidFile { detail } if !detail.is_empty()));
|
||||
|
||||
let existing: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
|
||||
assert!(import_from(dir.path(), &existing).imported.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn macros_env_files_and_missing_manage_py() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Sync.run.xml",
|
||||
&run_file(
|
||||
"Sync",
|
||||
&format!(
|
||||
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="$MODULE_DIR$/jobs" />
|
||||
<option name="SCRIPT_NAME" value="$MODULE_WORKING_DIR$/jobs/sync.py" />
|
||||
<option name="PARAMETERS" value="" />
|
||||
<extension name="net.ashald.envfile">
|
||||
<option name="IS_ENABLED" value="true" />
|
||||
<option name="IS_SUBST" value="false" />
|
||||
<ENTRIES>
|
||||
<ENTRY IS_ENABLED="true" PARSER="runconfig" IS_EXECUTABLE="false" />
|
||||
<ENTRY IS_ENABLED="true" PARSER="env" IS_EXECUTABLE="false" PATH=".env" />
|
||||
<ENTRY IS_ENABLED="false" PARSER="env" IS_EXECUTABLE="false" PATH=".env.off" />
|
||||
<ENTRY IS_ENABLED="true" PARSER="yaml" IS_EXECUTABLE="false" PATH="secrets.yml" />
|
||||
</ENTRIES>
|
||||
</extension>
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Repo.run.xml",
|
||||
&run_file(
|
||||
"Repo",
|
||||
&format!(
|
||||
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="SCRIPT_NAME" value="$MAVEN_REPOSITORY$/tool.py" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Server.run.xml",
|
||||
&run_file(
|
||||
"Server",
|
||||
&format!(
|
||||
r#"<configuration name="NAME" type="Python.DjangoServer" factoryName="Django server">
|
||||
{PY_COMMON}
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
let sync = get(&r, "Sync");
|
||||
assert_eq!(cwd(sync), Some(PathBuf::from("jobs")));
|
||||
assert_eq!(sync.args, s(&["sync.py"]));
|
||||
assert_eq!(sync.extras.env_files, s(&[".env"]));
|
||||
assert_eq!(ignored(&r, "Sync"), ["EnvFile secrets.yml"]);
|
||||
assert!(!sync.extras.local);
|
||||
assert_eq!(reason(&r, "Repo"), &SkipReason::PathVariable { variable: "$MAVEN_REPOSITORY$".into() });
|
||||
assert_eq!(reason(&r, "Server"), &SkipReason::MissingFile { file: "manage.py".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sources_are_offered_when_their_files_exist() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(available(dir.path()).is_empty());
|
||||
assert!(import(dir.path(), RunImportSource::Jetbrains, &[], &env()).is_err());
|
||||
// IDEA writes a workspace.xml everywhere: without configurations to import there's nothing to offer.
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(""));
|
||||
assert!(available(dir.path()).is_empty());
|
||||
write(
|
||||
dir.path(),
|
||||
".idea/workspace.xml",
|
||||
&workspace(
|
||||
r#"<configuration default="true" type="PythonConfigurationType" factoryName="Python" />
|
||||
<configuration name="once" type="js.build_tools.npm" temporary="true" />"#,
|
||||
),
|
||||
);
|
||||
assert!(available(dir.path()).is_empty());
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(r#"<configuration name="App" type="Application" />"#));
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Jetbrains]);
|
||||
write(dir.path(), ".vscode/launch.json", "{}");
|
||||
assert_eq!(available(dir.path()), [RunImportSource::Vscode, RunImportSource::Jetbrains]);
|
||||
assert_eq!(
|
||||
reason(&import_from(dir.path(), &[]), "App"),
|
||||
&SkipReason::UnsupportedType { type_: "Application".into() }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_configurations_round_trip_through_run_toml() {
|
||||
let dir = django_project();
|
||||
write(
|
||||
dir.path(),
|
||||
".run/Seed.run.xml",
|
||||
&run_file(
|
||||
"Seed",
|
||||
r#"<configuration name="NAME" type="ShConfigurationType" folderName="Data">
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/scripts/seed.sh" />
|
||||
<option name="INTERPRETER_PATH" value="" />
|
||||
<option name="EXECUTE_SCRIPT_FILE" value="true" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>"#,
|
||||
),
|
||||
);
|
||||
let r = import_from(dir.path(), &[]);
|
||||
let configs: Vec<RunConfig> = r.imported.iter().map(|c| c.config.clone()).collect();
|
||||
assert_eq!(configs[0].command, "./scripts/seed.sh");
|
||||
ide_run::save_user_configs(dir.path(), &configs).unwrap();
|
||||
assert_eq!(ide_run::load_user_configs(dir.path()).unwrap(), configs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn module_macros_need_a_single_module_project() {
|
||||
let launch = |root: &Path| {
|
||||
write(
|
||||
root,
|
||||
".run/Job.run.xml",
|
||||
&run_file(
|
||||
"Job",
|
||||
&format!(
|
||||
r#"<configuration name="NAME" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="SCRIPT_NAME" value="$MODULE_DIR$/job.py" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
),
|
||||
),
|
||||
)
|
||||
};
|
||||
let modules = |names: &[&str]| {
|
||||
let items: String = names
|
||||
.iter()
|
||||
.map(|n| format!("<module fileurl=\"file://$PROJECT_DIR$/{n}.iml\" filepath=\"$PROJECT_DIR$/{n}.iml\" />"))
|
||||
.collect();
|
||||
format!(
|
||||
"<project version=\"4\"><component name=\"ProjectModuleManager\"><modules>{items}</modules></component></project>"
|
||||
)
|
||||
};
|
||||
let one = tempfile::tempdir().unwrap();
|
||||
launch(one.path());
|
||||
write(one.path(), ".idea/modules.xml", &modules(&["shop"]));
|
||||
assert_eq!(get(&import_from(one.path(), &[]), "Job").args, s(&["job.py"]));
|
||||
|
||||
let two = tempfile::tempdir().unwrap();
|
||||
launch(two.path());
|
||||
write(two.path(), ".idea/modules.xml", &modules(&["shop", "worker"]));
|
||||
let r = import_from(two.path(), &[]);
|
||||
assert_eq!(reason(&r, "Job"), &SkipReason::SeveralModules { variable: "$MODULE_DIR$".into() });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pytest_python_targets_become_node_ids() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "tests/__init__.py", "");
|
||||
write(dir.path(), "tests/test_money.py", "");
|
||||
write(dir.path(), "api/tests/test_views.py", "");
|
||||
write(dir.path(), "pkg/sub/__init__.py", "");
|
||||
let case = |name: &str, target: &str, params: &str, wd: &str| {
|
||||
format!(
|
||||
r#"<configuration name="{name}" type="tests" factoryName="py.test">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="{wd}" />
|
||||
<option name="_new_parameters" value=""{params}"" />
|
||||
<option name="_new_target" value=""{target}"" />
|
||||
<option name="_new_targetType" value=""PYTHON"" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
)
|
||||
};
|
||||
let configs = [
|
||||
case("Function", "tests.test_money.test_round", "", ""),
|
||||
case("Method", "tests.test_money.TestRound.test_eur", "", ""),
|
||||
case("Parametrized", "tests.test_money.TestRound.test_eur", "eur-2", ""),
|
||||
case("Module", "tests.test_money", "", ""),
|
||||
case("Package", "pkg.sub", "", ""),
|
||||
case("From api", "tests.test_views.test_list", "", "$PROJECT_DIR$/api"),
|
||||
case("Root from api", "tests.test_money.test_round", "", "$PROJECT_DIR$/api/tests"),
|
||||
case("Installed", "somelib.tests.test_core", "x", ""),
|
||||
];
|
||||
write(dir.path(), ".idea/workspace.xml", &workspace(&configs.join("\n")));
|
||||
let r = import_from(dir.path(), &[]);
|
||||
let target = |name: &str| get(&r, name).args[2..].to_vec();
|
||||
assert_eq!(target("Function"), s(&["tests/test_money.py::test_round"]));
|
||||
assert_eq!(target("Method"), s(&["tests/test_money.py::TestRound::test_eur"]));
|
||||
assert_eq!(target("Parametrized"), s(&["tests/test_money.py::TestRound::test_eur[eur-2]"]));
|
||||
assert_eq!(target("Module"), s(&["tests/test_money.py"]));
|
||||
assert_eq!(target("Package"), s(&["pkg/sub"]));
|
||||
// From the working directory first, then the project root (relative to the working directory).
|
||||
assert_eq!(target("From api"), s(&["tests/test_views.py::test_list"]));
|
||||
assert_eq!(target("Root from api"), s(&["../../tests/test_money.py::test_round"]));
|
||||
// Not in the project: pytest looks the module up by name; the parameters can't follow it.
|
||||
assert_eq!(target("Installed"), s(&["--pyargs", "somelib.tests.test_core"]));
|
||||
assert_eq!(ignored(&r, "Installed"), ["_new_parameters"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_defaults_inherited_variables_and_nested_django_tests() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "backend/manage.py", "");
|
||||
write(
|
||||
dir.path(),
|
||||
".idea/workspace.xml",
|
||||
&workspace(&format!(
|
||||
r#" <configuration name="Old script" type="ShConfigurationType">
|
||||
<option name="SCRIPT_PATH" value="$PROJECT_DIR$/run.sh" />
|
||||
<option name="INTERPRETER_PATH" value="" />
|
||||
<envs><env name="PYTHONPATH" value="$PYTHONPATH$:src" /><env name="TAG" value="$NOT_SET$" /></envs>
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Tool off" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="RUN_TOOL" value="false" />
|
||||
<option name="SCRIPT_NAME" value="app.py" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Tool on" type="PythonConfigurationType" factoryName="Python">
|
||||
{PY_COMMON}
|
||||
<option name="RUN_TOOL" value="true" />
|
||||
<option name="SCRIPT_NAME" value="app.py" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Backend tests" type="DjangoTestsConfigurationType">
|
||||
{PY_COMMON}
|
||||
<option name="WORKING_DIRECTORY" value="" />
|
||||
<option name="TARGET" value="orders" />
|
||||
<method v="2" />
|
||||
</configuration>"#
|
||||
)),
|
||||
);
|
||||
let mut env = env();
|
||||
env.insert("PYTHONPATH".into(), "/opt/lib".into());
|
||||
let r = import(dir.path(), RunImportSource::Jetbrains, &[], &env).unwrap();
|
||||
|
||||
// Written before EXECUTE_SCRIPT_FILE / EXECUTE_IN_TERMINAL existed: both default to true.
|
||||
let old = get(&r, "Old script");
|
||||
// Run directly: `./run.sh`, not a `run.sh` looked up on the PATH.
|
||||
assert_eq!(old.command, "./run.sh");
|
||||
assert_eq!(old.extras.params["path"], "./run.sh");
|
||||
assert!(old.extras.emulate_terminal);
|
||||
// Environment values as written, as the Shell and Python types pass them: `$NAME$` isn't inlined,
|
||||
// even for a variable runs inherit, and doesn't make the configuration this machine's.
|
||||
assert_eq!(old.env["PYTHONPATH"], "$PYTHONPATH$:src");
|
||||
assert_eq!(old.env["TAG"], "$NOT_SET$");
|
||||
assert!(!old.extras.local);
|
||||
|
||||
assert!(ignored(&r, "Tool off").is_empty());
|
||||
assert_eq!(ignored(&r, "Tool on"), ["RUN_TOOL"]);
|
||||
|
||||
// `backend/manage.py test` from the root: a Python run, still a test configuration.
|
||||
let tests = get(&r, "Backend tests");
|
||||
assert_eq!((tests.kind.as_str(), tests.is_test), ("python", true));
|
||||
assert_eq!(tests.args[..3], s(&["backend/manage.py", "test", "orders"]));
|
||||
}
|
||||
+161
-22
@@ -192,6 +192,88 @@ pub fn maven_options(root: &Path, s: &Settings) -> Vec<String> {
|
||||
out
|
||||
}
|
||||
|
||||
fn maven_settings_prefixes(args: &[String]) -> Vec<usize> {
|
||||
let mut prefixes = vec![0];
|
||||
let mut i = 0;
|
||||
while let Some(arg) = args.get(i) {
|
||||
if arg == "-s" && args.get(i + 1).is_some() {
|
||||
i += 2;
|
||||
} else if arg.starts_with("-Dmaven.repo.local=") {
|
||||
i += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
prefixes.push(i);
|
||||
}
|
||||
prefixes
|
||||
}
|
||||
|
||||
fn generated_maven_goal(config: &RunConfig) -> bool {
|
||||
config.id.starts_with("maven:goal:") && config.extras.params.is_empty()
|
||||
}
|
||||
|
||||
fn maven_goal_prefix(config: &RunConfig) -> Option<usize> {
|
||||
let (_, goals) = config.id.split_once(".xml:")?;
|
||||
maven_settings_prefixes(&config.args)
|
||||
.into_iter()
|
||||
.find(|&start| (start + 1..=config.args.len()).any(|end| config.args[start..end].join(" ") == goals))
|
||||
}
|
||||
|
||||
/// Applies the current Maven settings at launch, including temporary configurations
|
||||
/// made before build-tool defaults stopped being stored in their command and arguments.
|
||||
pub fn prepare_maven_run(config: &mut RunConfig, root: &Path, s: &Settings) {
|
||||
let mut detected = config.detected;
|
||||
if generated_maven_goal(config) {
|
||||
// The id records the user's original goals; only the settings prefix before them is derived.
|
||||
if let Some(prefix) = maven_goal_prefix(config) {
|
||||
config.args.drain(..prefix);
|
||||
}
|
||||
let name = config.command.rsplit(['/', '\\']).next().unwrap_or_default().to_ascii_lowercase();
|
||||
if !matches!(name.as_str(), "mvnw" | "mvnw.cmd" | "mvnw.bat") {
|
||||
config.command = "mvn".into();
|
||||
if maven_command(root, s).is_none() {
|
||||
let cwd = config.cwd.as_ref().map_or_else(|| root.to_path_buf(), |dir| root.join(dir.as_ref()));
|
||||
for dir in cwd.ancestors().take_while(|dir| dir.starts_with(root)) {
|
||||
let wrapper = dir.join(if cfg!(windows) { "mvnw.cmd" } else { "mvnw" });
|
||||
if wrapper.is_file() {
|
||||
config.command = wrapper.to_string_lossy().into_owned();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if config.detected || config.id.starts_with("context:") {
|
||||
let saved = ide_run::load_user_configs(root).unwrap_or_default();
|
||||
let id = config
|
||||
.id
|
||||
.strip_prefix("context:target:context:")
|
||||
.or_else(|| config.id.strip_prefix("context:"))
|
||||
.unwrap_or(&config.id);
|
||||
let id = id.strip_prefix("workspace:").and_then(|id| id.split_once(':')).map_or(id, |(_, id)| id);
|
||||
let source = ide_run::detect(root)
|
||||
.into_iter()
|
||||
.chain(saved.iter().cloned())
|
||||
.filter(|base| base.kind == "maven" && (id == base.id || id.starts_with(&format!("{}:", base.id))))
|
||||
.max_by_key(|base| base.id.len())
|
||||
.filter(|base| !saved.iter().any(|user| user.id == base.id));
|
||||
if let Some(base) = source.filter(|_| config.extras.params.is_empty()) {
|
||||
if let Some(prefix) = maven_settings_prefixes(&config.args)
|
||||
.into_iter()
|
||||
.find(|&start| config.args[start..].starts_with(&base.args))
|
||||
{
|
||||
config.args.drain(..prefix);
|
||||
}
|
||||
config.command = base.command;
|
||||
} else {
|
||||
detected = false;
|
||||
}
|
||||
}
|
||||
let original = config.detected;
|
||||
config.detected = detected;
|
||||
apply_to_run_configs(std::slice::from_mut(config), root, s);
|
||||
config.detected = original;
|
||||
}
|
||||
|
||||
/// The Maven local repository from settings: the configured one, else
|
||||
/// `<localRepository>` of the configured user `settings.xml`; `None` = Maven's default.
|
||||
pub fn maven_local_repository(root: &Path, s: &Settings) -> Option<PathBuf> {
|
||||
@@ -541,6 +623,8 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
}),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
@@ -562,11 +646,17 @@ pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment, `initializationOptions` and `workspace/configuration` settings
|
||||
/// from Settings › Language Servers; applied last so they win over derived values.
|
||||
/// Environment, features, `initializationOptions` and `workspace/configuration`
|
||||
/// settings from Settings › Language Servers; applied last so they win over derived values.
|
||||
pub fn server_protocol_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
|
||||
let Some(ls) = s.language_servers.get(&cfg.name) else { return };
|
||||
cfg.env.extend(ls.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
if let Some(features) = &ls.features {
|
||||
cfg.features = features.clone();
|
||||
}
|
||||
if let Some(kinds) = &ls.code_action_kinds {
|
||||
cfg.code_action_kinds = kinds.clone();
|
||||
}
|
||||
if let Some(options) = &ls.initialization_options {
|
||||
merge_init_options(cfg, options.clone());
|
||||
}
|
||||
@@ -639,6 +729,40 @@ fn is_python_command(cmd: &str) -> bool {
|
||||
|
||||
const PY_RUNNERS: [&str; 3] = ["uv", "poetry", "pdm"];
|
||||
|
||||
/// [`maven_options`] as the groups they are passed in (`-s <file>`, `-Dmaven.repo.local=…`).
|
||||
fn maven_option_groups(options: &[String]) -> Vec<&[String]> {
|
||||
let mut groups = Vec::new();
|
||||
let mut i = 0;
|
||||
while i < options.len() {
|
||||
let n = if options[i] == "-s" { 2.min(options.len() - i) } else { 1 };
|
||||
groups.push(&options[i..i + n]);
|
||||
i += n;
|
||||
}
|
||||
groups
|
||||
}
|
||||
|
||||
/// Whether `args` set what `group` sets: a user settings file, or the local repository.
|
||||
fn sets_maven_option(args: &[String], group: &[String]) -> bool {
|
||||
match group.first().map(String::as_str) {
|
||||
Some("-s") => args.iter().any(|a| a == "-s" || a == "--settings" || a.starts_with("--settings=")),
|
||||
Some(flag) => {
|
||||
let key = flag.split_once('=').map_or(flag, |(k, _)| k);
|
||||
args.iter().any(|a| a.split_once('=').map_or(a.as_str(), |(k, _)| k) == key)
|
||||
|| args
|
||||
.windows(2)
|
||||
.any(|a| a[0] == "-D" && format!("-D{}", a[1].split('=').next().unwrap_or_default()) == key)
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings › Maven's options ahead of the goals, each unless the configuration sets it itself.
|
||||
fn add_maven_options(args: &mut Vec<String>, options: &[String]) {
|
||||
let missing: Vec<String> =
|
||||
maven_option_groups(options).into_iter().filter(|g| !sets_maven_option(args, g)).flatten().cloned().collect();
|
||||
args.splice(0..0, missing);
|
||||
}
|
||||
|
||||
/// Points run configurations at the selected toolchains: Python configs use the
|
||||
/// project interpreter, npm scripts the chosen package manager and Node.js,
|
||||
/// cargo / go configs the selected toolchain, Maven / Gradle the project JDK
|
||||
@@ -695,8 +819,10 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
{
|
||||
c.command = pm.to_owned();
|
||||
}
|
||||
if let Some(dir) = &node {
|
||||
path_dirs.push(dir.clone());
|
||||
// The configuration's own Node interpreter, else the project's.
|
||||
let own = c.extras.params.get("nodeInterpreter").and_then(|p| setting_path(root, p));
|
||||
if let Some(dir) = own.and_then(|p| p.parent().map(Path::to_path_buf)).or_else(|| node.clone()) {
|
||||
path_dirs.push(dir);
|
||||
}
|
||||
}
|
||||
"php" | "phpunit" | "composer" => {
|
||||
@@ -773,24 +899,15 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
if let Some(jdk) = &jdk {
|
||||
c.env.entry("JAVA_HOME".into()).or_insert_with(|| jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
if c.kind == "maven" && c.detected {
|
||||
if let Some(mvn) = &mvn {
|
||||
if c.kind == "maven" {
|
||||
if let Some(mvn) = &mvn
|
||||
&& (c.detected
|
||||
|| generated_maven_goal(c)
|
||||
|| matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat"))
|
||||
{
|
||||
c.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
if !c.args.starts_with(&mvn_options) {
|
||||
c.args.splice(0..0, mvn_options.iter().cloned());
|
||||
}
|
||||
} else if c.kind == "maven"
|
||||
&& matches!(c.command.as_str(), "mvn" | "mvn.cmd" | "mvn.bat")
|
||||
&& let Some(mvn) = &mvn
|
||||
{
|
||||
// A goal configuration saved from the tool window keeps the
|
||||
// literal `mvn` / `mvn.cmd` the launcher resolved to; without a
|
||||
// wrapper it cannot start when Maven lives on a version
|
||||
// manager's shims or a configured home (#328). Detected configs
|
||||
// take the Maven options too, but the saved ones already baked
|
||||
// them into `args`.
|
||||
c.command = mvn.to_string_lossy().into_owned();
|
||||
add_maven_options(&mut c.args, &mvn_options);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
@@ -817,19 +934,41 @@ pub fn without_derived(config: &RunConfig, root: &Path, s: &Settings) -> RunConf
|
||||
};
|
||||
let mut out = config.clone();
|
||||
// Until nothing changes: some entries are derived only once others are gone
|
||||
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`).
|
||||
// (the virtualenv's `PATH` is added only without `VIRTUAL_ENV`). An entry derived before
|
||||
// the configuration's own choice (an npm script's Node interpreter) is derived too.
|
||||
while let Some(key) = out
|
||||
.env
|
||||
.iter()
|
||||
.find(|(key, value)| {
|
||||
let mut probe = out.clone();
|
||||
probe.env.remove(*key);
|
||||
applied(&probe).env.get(*key) == Some(*value)
|
||||
let mut plain = probe.clone();
|
||||
plain.extras.params.clear();
|
||||
applied(&probe).env.get(*key) == Some(*value) || applied(&plain).env.get(*key) == Some(*value)
|
||||
})
|
||||
.map(|(key, _)| key.clone())
|
||||
{
|
||||
out.env.remove(&key);
|
||||
}
|
||||
// Settings › Maven's options, which applying puts back ahead of the goals.
|
||||
if out.kind == "maven" {
|
||||
let options = maven_options(root, s);
|
||||
let mut probe = out.clone();
|
||||
let mut prefix = generated_maven_goal(&out).then(|| maven_goal_prefix(&out)).flatten();
|
||||
for group in maven_option_groups(&options) {
|
||||
if let Some(at) = probe.args.windows(group.len()).position(|w| w == group)
|
||||
&& prefix.is_none_or(|limit| at + group.len() <= limit)
|
||||
{
|
||||
probe.args.drain(at..at + group.len());
|
||||
if let Some(limit) = &mut prefix {
|
||||
*limit -= group.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
if applied(&probe).args == out.args {
|
||||
out.args = probe.args;
|
||||
}
|
||||
}
|
||||
let path = Path::new(&config.command);
|
||||
if path.is_absolute()
|
||||
&& let Some(name) = path.file_name().map(|n| n.to_string_lossy().into_owned())
|
||||
|
||||
@@ -545,7 +545,7 @@ fn jdk() -> Vec<Sdk> {
|
||||
|
||||
/// A JDK as the SDK pickers list it (`Eclipse Adoptium 25.0.1`).
|
||||
pub fn jdk_sdk(j: &crate::java::Jdk) -> Sdk {
|
||||
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| dunce::canonicalize(h).ok());
|
||||
let java_home = std::env::var_os("JAVA_HOME").and_then(|h| crate::launch_path(Path::new(&h)).ok());
|
||||
let release = std::fs::read_to_string(j.home.join("release")).unwrap_or_default();
|
||||
let version = release_value(&release, "JAVA_VERSION").unwrap_or_else(|| j.major.to_string());
|
||||
let name = match release_value(&release, "IMPLEMENTOR") {
|
||||
@@ -595,7 +595,7 @@ fn maven() -> Vec<Sdk> {
|
||||
} else {
|
||||
which("mvn")
|
||||
};
|
||||
if let Some(mvn) = on_path.and_then(|p| dunce::canonicalize(p).ok())
|
||||
if let Some(mvn) = on_path.and_then(|p| crate::launch_path(&p).ok())
|
||||
&& let Some(home) = mvn.parent().and_then(Path::parent)
|
||||
{
|
||||
found.push((home.to_path_buf(), "path"));
|
||||
@@ -629,7 +629,7 @@ fn maven() -> Vec<Sdk> {
|
||||
let mut out = Vec::new();
|
||||
for (dir, source) in found {
|
||||
let Some(home) = maven_home(&dir) else { continue };
|
||||
let home = dunce::canonicalize(&home).unwrap_or(home);
|
||||
let home = crate::launch_path(&home).unwrap_or(home);
|
||||
if !seen.insert(home.clone()) {
|
||||
continue;
|
||||
}
|
||||
@@ -645,3 +645,88 @@ fn maven() -> Vec<Sdk> {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(all(test, windows))]
|
||||
mod maven_path_tests {
|
||||
use super::*;
|
||||
use crate::launch_path;
|
||||
use std::process::Command;
|
||||
|
||||
struct Subst {
|
||||
name: String,
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Subst {
|
||||
fn new(target: &Path) -> Self {
|
||||
// Tests run in parallel and may race for the same free letter; only a drive that
|
||||
// shows this target's marker is ours.
|
||||
let marker = format!("{}.subst", target.file_name().unwrap().to_string_lossy());
|
||||
std::fs::write(target.join(&marker), "").unwrap();
|
||||
for letter in (b'D'..=b'Z').rev() {
|
||||
let name = format!("{}:", char::from(letter));
|
||||
let root = PathBuf::from(format!("{name}\\"));
|
||||
if root.exists() {
|
||||
continue;
|
||||
}
|
||||
let output = Command::new("subst").arg(&name).arg(target).output().unwrap();
|
||||
if output.status.success() && root.join(&marker).exists() {
|
||||
return Self { name, root };
|
||||
}
|
||||
}
|
||||
panic!("no free drive for Maven path verification");
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Subst {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("subst").args([&self.name, "/D"]).output();
|
||||
}
|
||||
}
|
||||
|
||||
fn install(home: &Path) {
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
std::fs::create_dir_all(home.join("lib")).unwrap();
|
||||
std::fs::write(home.join("bin/mvn.cmd"), "@echo off\n").unwrap();
|
||||
std::fs::write(home.join("lib/maven-core-3.9.16.jar"), "").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mapped_maven_home_and_launcher_keep_their_drive() {
|
||||
let mapped = tempfile::tempdir().unwrap();
|
||||
install(&mapped.path().join("apache-maven"));
|
||||
let drive = Subst::new(mapped.path());
|
||||
let home = drive.root.join("apache-maven");
|
||||
let launcher = home.join("bin/mvn.cmd");
|
||||
assert_ne!(dunce::canonicalize(&home).unwrap(), home);
|
||||
assert_eq!(launch_path(&home).unwrap(), home);
|
||||
assert_eq!(launch_path(&launcher).unwrap(), launcher);
|
||||
assert_eq!(maven_home(&home), Some(home.clone()));
|
||||
assert_eq!(maven_version(&home).as_deref(), Some("3.9.16"));
|
||||
let jdk = drive.root.join("jdk");
|
||||
std::fs::create_dir_all(jdk.join("bin")).unwrap();
|
||||
std::fs::write(jdk.join("bin/java.exe"), "").unwrap();
|
||||
std::fs::write(jdk.join("release"), "JAVA_VERSION=\"17.0.9\"\n").unwrap();
|
||||
assert_eq!(crate::java::jdk_at(&jdk).unwrap().home, jdk);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_link_outside_the_mapped_root_still_finds_its_install() {
|
||||
let mapped = tempfile::tempdir().unwrap();
|
||||
let real = tempfile::tempdir().unwrap();
|
||||
install(real.path());
|
||||
let drive = Subst::new(mapped.path());
|
||||
let link = mapped.path().join("shims");
|
||||
let output = Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()))
|
||||
.args(["/D", "/C", "mklink", "/J"])
|
||||
.arg(&link)
|
||||
.arg(real.path().join("bin"))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
let launcher = launch_path(&drive.root.join("shims/mvn.cmd")).unwrap();
|
||||
assert_eq!(launcher, dunce::canonicalize(real.path().join("bin/mvn.cmd")).unwrap());
|
||||
let home = launcher.parent().and_then(Path::parent).and_then(maven_home).unwrap();
|
||||
assert_eq!(home, dunce::canonicalize(real.path()).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -38,10 +38,7 @@ pub fn jdk_at(home: &Path) -> Option<Jdk> {
|
||||
if !home.join("bin").join(crate::exe("java")).is_file() {
|
||||
return None;
|
||||
}
|
||||
Some(Jdk {
|
||||
major: parse_release(&release)?,
|
||||
home: dunce::canonicalize(home).unwrap_or_else(|_| home.to_path_buf()),
|
||||
})
|
||||
Some(Jdk { major: parse_release(&release)?, home: crate::launch_path(home).unwrap_or_else(|_| home.to_path_buf()) })
|
||||
}
|
||||
|
||||
fn children(dir: &Path) -> Vec<PathBuf> {
|
||||
@@ -90,7 +87,7 @@ fn scan() -> Vec<Jdk> {
|
||||
let path = std::env::var_os("PATH").unwrap_or_default();
|
||||
if let Some(java) = std::env::split_paths(&path).map(|d| d.join(crate::exe("java"))).find(|j| j.is_file()) {
|
||||
// Through symlinks (`/usr/bin/java`, Homebrew, SDKMAN `current`) to `<home>/bin/java`.
|
||||
if let Some(home) = dunce::canonicalize(java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) {
|
||||
if let Some(home) = crate::launch_path(&java).ok().and_then(|j| j.parent()?.parent().map(Path::to_path_buf)) {
|
||||
homes.push(home);
|
||||
}
|
||||
}
|
||||
@@ -1173,7 +1170,7 @@ mod kotlin_bridge_bundle_tests {
|
||||
|
||||
/// Installs the app-owned JDT LS command bundle, falling back to ordinary LSP on IO failure.
|
||||
pub fn usages_bundle(data_dir: &Path) -> Option<PathBuf> {
|
||||
let extension = data_dir.join("java-usages-1.2.1.jar");
|
||||
let extension = data_dir.join("java-usages-1.3.0.jar");
|
||||
let bytes = include_bytes!("../../../plugins/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
+42
-24
@@ -105,15 +105,17 @@ fn split_script_args(args: &[String]) -> ScriptArgs {
|
||||
out
|
||||
}
|
||||
|
||||
/// JVM options jdtls runs with unless the user's set the same thing: a parallel collector
|
||||
/// tuned to give memory back to the OS (resident memory stays near what is live instead of
|
||||
/// growing to the maximum heap), a 2 GB heap limit (the JVM's own is a quarter of physical
|
||||
/// JVM options jdtls runs with unless the user's set the same thing: G1 with a periodic
|
||||
/// collection while idle and a narrow free-heap band, so the heap shrinks and memory goes
|
||||
/// back to the OS once an import or a build is over (a parallel collector keeps its peak
|
||||
/// committed for good), a 2 GB heap limit (the JVM's own is a quarter of physical
|
||||
/// memory), no JVM logging on stdout (the LSP channel), and no scan for installed JDKs at
|
||||
/// start (they are passed as `java.configuration.runtimes`).
|
||||
pub const DEFAULT_VM_ARGS: &[&str] = &[
|
||||
"-XX:+UseParallelGC",
|
||||
"-XX:GCTimeRatio=4",
|
||||
"-XX:AdaptiveSizePolicyWeight=90",
|
||||
"-XX:+UseG1GC",
|
||||
"-XX:G1PeriodicGCInterval=60000",
|
||||
"-XX:MinHeapFreeRatio=10",
|
||||
"-XX:MaxHeapFreeRatio=30",
|
||||
"-Dsun.zip.disableMemoryMapping=true",
|
||||
"-Xmx2G",
|
||||
"-Xms100m",
|
||||
@@ -130,6 +132,8 @@ fn option_key(arg: &str) -> Option<String> {
|
||||
let name = name.split_once('=').map_or(name, |(n, _)| n);
|
||||
match name {
|
||||
"MaxHeapSize" | "MaxRAM" | "MaxRAMPercentage" | "MaxRAMFraction" => "Xmx".to_owned(),
|
||||
// The JVM rejects a minimum above the maximum, so they go together.
|
||||
"MinHeapFreeRatio" | "MaxHeapFreeRatio" => "XX:HeapFreeRatio".to_owned(),
|
||||
"InitialHeapSize" | "InitialRAMPercentage" | "InitialRAMFraction" => "Xms".to_owned(),
|
||||
_ if is_collector(name) => "collector".to_owned(),
|
||||
_ => format!("XX:{name}"),
|
||||
@@ -153,8 +157,8 @@ fn is_collector(name: &str) -> bool {
|
||||
name.starts_with("Use") && name.ends_with("GC") && name.len() > "UseGC".len()
|
||||
}
|
||||
|
||||
/// Our tuning of the parallel collector, meaningless (or rejected) with another one.
|
||||
const COLLECTOR_TUNING: &[&str] = &["GCTimeRatio", "AdaptiveSizePolicyWeight"];
|
||||
/// Our tuning of the default collector, meant for it alone.
|
||||
const COLLECTOR_TUNING: &[&str] = &["G1PeriodicGCInterval", "HeapFreeRatio"];
|
||||
|
||||
/// [`DEFAULT_VM_ARGS`] less what `user` (the JVM options already on the command line)
|
||||
/// sets itself. Choosing a collector drops our collector and its tuning altogether.
|
||||
@@ -180,7 +184,10 @@ pub fn add_default_vm_args(args: &mut Vec<String>) {
|
||||
}
|
||||
|
||||
/// The JVM command line `bin/jdtls` builds: its system properties and module
|
||||
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-data`.
|
||||
/// options, the user's `--jvm-arg`s, then the Equinox launcher with `-nosplash` and
|
||||
/// `-data`. Without `-nosplash` the launcher's native library loads AppKit on macOS,
|
||||
/// which registers the server as an app that never answers events and shows as
|
||||
/// "Not Responding" in Activity Monitor.
|
||||
pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]) -> Vec<String> {
|
||||
let parsed = split_script_args(script_args);
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
@@ -211,6 +218,9 @@ pub fn java_args(install: &JdtlsInstall, java_major: u32, script_args: &[String]
|
||||
);
|
||||
args.extend(parsed.jvm);
|
||||
args.extend(["-jar".to_owned(), install.launcher.to_string_lossy().into_owned()]);
|
||||
if !parsed.rest.iter().any(|a| a == "-nosplash") {
|
||||
args.push("-nosplash".to_owned());
|
||||
}
|
||||
if let Some(data) = parsed.data {
|
||||
args.extend(["-data".to_owned(), data]);
|
||||
}
|
||||
@@ -342,6 +352,7 @@ mod tests {
|
||||
&format!("-javaagent:{agent}"),
|
||||
"-jar",
|
||||
&format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.v20250424-1814.jar"),
|
||||
"-nosplash",
|
||||
"-data",
|
||||
data,
|
||||
])
|
||||
@@ -356,7 +367,9 @@ mod tests {
|
||||
DEFAULT_VM_ARGS.iter().filter(|a| !drop.contains(a)).map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
const PARALLEL: &[&str] = &["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-XX:AdaptiveSizePolicyWeight=90"];
|
||||
const G1: &[&str] =
|
||||
&["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
|
||||
const HEAP_FREE: &[&str] = &["-XX:MinHeapFreeRatio=10", "-XX:MaxHeapFreeRatio=30"];
|
||||
|
||||
#[test]
|
||||
fn user_options_override_the_defaults() {
|
||||
@@ -369,13 +382,15 @@ mod tests {
|
||||
(&["-XX:MaxRAMPercentage=40"], &["-Xmx2G"]),
|
||||
(&["-Xms1G"], &["-Xms100m"]),
|
||||
(&["-XX:InitialHeapSize=256m"], &["-Xms100m"]),
|
||||
(&["-XX:+UseG1GC"], PARALLEL),
|
||||
(&["-XX:+UseZGC", "-XX:+ZGenerational"], PARALLEL),
|
||||
(&["-XX:+UseShenandoahGC"], PARALLEL),
|
||||
(&["-XX:+UseSerialGC"], PARALLEL),
|
||||
(&["-XX:-UseParallelGC"], PARALLEL),
|
||||
(&["-XX:+UseParallelGC"], PARALLEL),
|
||||
(&["-XX:GCTimeRatio=9"], &["-XX:GCTimeRatio=4"]),
|
||||
(&["-XX:+UseParallelGC"], G1),
|
||||
(&["-XX:+UseZGC", "-XX:+ZGenerational"], G1),
|
||||
(&["-XX:+UseShenandoahGC"], G1),
|
||||
(&["-XX:+UseSerialGC"], G1),
|
||||
(&["-XX:-UseG1GC"], G1),
|
||||
(&["-XX:+UseG1GC"], G1),
|
||||
(&["-XX:G1PeriodicGCInterval=0"], &["-XX:G1PeriodicGCInterval=60000"]),
|
||||
(&["-XX:MinHeapFreeRatio=40"], HEAP_FREE),
|
||||
(&["-XX:MaxHeapFreeRatio=70"], HEAP_FREE),
|
||||
(&["-Xlog:gc*:file=gc.log"], &["-Xlog:disable"]),
|
||||
(&["-Dsun.zip.disableMemoryMapping=false"], &["-Dsun.zip.disableMemoryMapping=true"]),
|
||||
(&["-Daether.dependencyCollector.impl=df"], &["-Daether.dependencyCollector.impl=bf"]),
|
||||
@@ -389,10 +404,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn defaults_go_ahead_of_the_users_options_once() {
|
||||
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseG1GC"]);
|
||||
let user = strings(&["--jvm-arg=-Xmx4G", "-data", "/ws", "--jvm-arg", "-XX:+UseZGC"]);
|
||||
let mut args = user.clone();
|
||||
add_default_vm_args(&mut args);
|
||||
let dropped: Vec<&str> = PARALLEL.iter().copied().chain(["-Xmx2G"]).collect();
|
||||
let dropped: Vec<&str> = G1.iter().copied().chain(["-Xmx2G"]).collect();
|
||||
let expected: Vec<String> =
|
||||
without(&dropped).into_iter().map(|a| format!("--jvm-arg={a}")).chain(user).collect();
|
||||
assert_eq!(args, expected);
|
||||
@@ -413,14 +428,14 @@ mod tests {
|
||||
let args = java_args(&install, 25, &script);
|
||||
let jar = args.iter().position(|a| a == "-jar").unwrap();
|
||||
let jvm = &args[..jar];
|
||||
for flag in ["-XX:+UseParallelGC", "-XX:GCTimeRatio=4", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
|
||||
for flag in ["-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=60000", "-Xms100m", "-Xlog:disable", "-Xmx3G"] {
|
||||
assert!(jvm.contains(&flag.to_owned()), "{flag}: {jvm:?}");
|
||||
}
|
||||
let heap: Vec<&String> = jvm.iter().filter(|a| option_key(a).as_deref() == Some("Xmx")).collect();
|
||||
assert_eq!(heap, ["-Xmx3G"], "one heap limit, the user's");
|
||||
assert!(!jvm.iter().any(|a| a == "-Xms1G"), "{jvm:?}");
|
||||
assert_eq!(args[jar + 1], format!(r"{tools}\plugins\org.eclipse.equinox.launcher_1.7.0.jar"));
|
||||
assert_eq!(args[jar + 2..], strings(&["-data", r"C:\Users\张 三\ws"]));
|
||||
assert_eq!(args[jar + 2..], strings(&["-nosplash", "-data", r"C:\Users\张 三\ws"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -439,18 +454,21 @@ mod tests {
|
||||
assert_eq!(args[0], "-Declipse.application=org.eclipse.jdt.ls.core.id1", "no JDK 24+ flags on 21");
|
||||
assert!(args.contains(&"-Dosgi.sharedConfiguration.area=/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/config_mac_arm".to_owned()));
|
||||
assert_eq!(
|
||||
args[args.len() - 7..],
|
||||
args[args.len() - 8..],
|
||||
strings(&[
|
||||
"java.base/java.lang=ALL-UNNAMED",
|
||||
"-Dlog.level=ALL",
|
||||
"-jar",
|
||||
"/Users/me/Library/Application Support/ai.l0ng.openidea/tools/jdtls/plugins/org.eclipse.equinox.launcher_1.7.0.jar",
|
||||
"-nosplash",
|
||||
"-data",
|
||||
"/d",
|
||||
"--extra",
|
||||
])
|
||||
);
|
||||
assert!(!args.iter().any(|a| a.contains("validate-java-version")));
|
||||
let own = java_args(&install, 21, &strings(&["-data", "/d", "-nosplash"]));
|
||||
assert_eq!(own.iter().filter(|a| *a == "-nosplash").count(), 1, "{own:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -526,7 +544,7 @@ mod tests {
|
||||
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
|
||||
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
|
||||
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
|
||||
assert_eq!(direct.args[jar + 2..], strings(&["-data", "/ws"]));
|
||||
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", "/ws"]));
|
||||
|
||||
let jre = dir.path().join("jre-25");
|
||||
std::fs::create_dir_all(jre.join("bin")).unwrap();
|
||||
@@ -640,7 +658,7 @@ mod tests {
|
||||
assert!(direct.args.iter().any(|a| a == "-Xmx1G"));
|
||||
let jar = direct.args.iter().position(|a| a == "-jar").unwrap();
|
||||
assert!(direct.args[jar + 1].ends_with("org.eclipse.equinox.launcher_1.7.0.jar"));
|
||||
assert_eq!(direct.args[jar + 2..], strings(&["-data", r"C:\ws"]));
|
||||
assert_eq!(direct.args[jar + 2..], strings(&["-nosplash", "-data", r"C:\ws"]));
|
||||
|
||||
// A shim whose descriptor is gone is someone's own wrapper: keep the command.
|
||||
std::fs::remove_file(shims.join("jdtls.shim")).unwrap();
|
||||
|
||||
@@ -82,6 +82,36 @@ pub(crate) fn exe(name: &str) -> String {
|
||||
if cfg!(windows) { format!("{name}.exe") } else { name.to_owned() }
|
||||
}
|
||||
|
||||
/// `path` with symlinks resolved (Homebrew's `bin/mvn` → its Cellar), except that a Windows
|
||||
/// mapped drive letter is kept when the target stays inside that drive: canonicalizing gives
|
||||
/// `\\?\UNC\server\share\…` or another drive, and cmd.exe can't start a script from a
|
||||
/// `\\?\` path (`mvn.cmd`, or `mvn.cmd` with such a `JAVA_HOME`), nor is it the path the user knows.
|
||||
pub(crate) fn launch_path(path: &Path) -> std::io::Result<PathBuf> {
|
||||
let real = dunce::canonicalize(path)?;
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::path::{Component, Prefix};
|
||||
let drive = |p: &Path| match p.components().next() {
|
||||
Some(Component::Prefix(x)) => match x.kind() {
|
||||
Prefix::Disk(d) | Prefix::VerbatimDisk(d) => Some(d.to_ascii_uppercase()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(d) = drive(path)
|
||||
&& drive(&real) != Some(d)
|
||||
{
|
||||
let root = PathBuf::from(format!("{}:\\", char::from(d)));
|
||||
if let Ok(mapped) = dunce::canonicalize(&root)
|
||||
&& let Ok(relative) = real.strip_prefix(mapped)
|
||||
{
|
||||
return Ok(root.join(relative));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
pub(crate) fn home_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from)
|
||||
}
|
||||
|
||||
@@ -294,6 +294,36 @@ pub(crate) fn dependency_libraries(
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The `.toml` of a version catalog published as `group:artifact:version` (Gradle's
|
||||
/// `version-catalog` plugin) that a build imports with `from("…")`: from Gradle's cache, else
|
||||
/// the local Maven repository. `None` until a build has downloaded it.
|
||||
pub fn published_version_catalog(root: &Path, s: &Settings, coordinates: &str) -> Option<PathBuf> {
|
||||
let mut parts = coordinates.trim().split(':');
|
||||
let d = BuildDependency {
|
||||
gradle: true,
|
||||
group: parts.next()?.to_owned(),
|
||||
artifact: parts.next()?.to_owned(),
|
||||
version: parts.next()?.to_owned(),
|
||||
};
|
||||
if parts.next().is_some() || !valid_coordinate(&d) {
|
||||
return None;
|
||||
}
|
||||
let file = format!("{}-{}.toml", d.artifact, d.version);
|
||||
let gradle = crate::apply::setting_path(root, &s.languages.java.gradle.user_home).or_else(gradle_user_home);
|
||||
let cached = gradle.and_then(|home| {
|
||||
let dir = home.join("caches/modules-2/files-2.1").join(&d.group).join(&d.artifact).join(&d.version);
|
||||
let mut found: Vec<PathBuf> =
|
||||
std::fs::read_dir(dir).ok()?.flatten().map(|e| e.path().join(&file)).filter(|p| p.is_file()).collect();
|
||||
found.sort();
|
||||
found.into_iter().next()
|
||||
});
|
||||
cached.or_else(|| {
|
||||
let repo = crate::apply::maven_local_repository(root, s).or_else(default_maven_repository)?;
|
||||
let path = repo.join(d.group.replace('.', "/")).join(&d.artifact).join(&d.version).join(&file);
|
||||
path.is_file().then_some(path)
|
||||
})
|
||||
}
|
||||
|
||||
/// No path separators, interpolations or `..` segments (coordinates become paths).
|
||||
fn valid_coordinate(d: &BuildDependency) -> bool {
|
||||
[&d.group, &d.artifact, &d.version]
|
||||
@@ -530,6 +560,26 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
|
||||
#[cfg(test)]
|
||||
mod go_cache_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finds_published_version_catalogs_in_the_gradle_cache_and_maven_repository() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.gradle.user_home = root.join("gradle").to_string_lossy().into_owned();
|
||||
s.languages.java.maven.local_repository = root.join("m2").to_string_lossy().into_owned();
|
||||
let cached = root.join("gradle/caches/modules-2/files-2.1/com.acme/catalog/1.2/abc123/catalog-1.2.toml");
|
||||
std::fs::create_dir_all(cached.parent().unwrap()).unwrap();
|
||||
std::fs::write(&cached, "[libraries]").unwrap();
|
||||
assert_eq!(published_version_catalog(root, &s, "com.acme:catalog:1.2"), Some(cached));
|
||||
let local = root.join("m2/com/acme/other/2.0/other-2.0.toml");
|
||||
std::fs::create_dir_all(local.parent().unwrap()).unwrap();
|
||||
std::fs::write(&local, "[libraries]").unwrap();
|
||||
assert_eq!(published_version_catalog(root, &s, " com.acme:other:2.0 "), Some(local));
|
||||
for missing in ["com.acme:catalog:9", "com.acme:catalog", "a:b:c:d", "../x:y:z"] {
|
||||
assert_eq!(published_version_catalog(root, &s, missing), None, "{missing}");
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn vendor_roots_include_local_replacements_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -33,6 +33,8 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
features: Vec::new(),
|
||||
code_action_kinds: Vec::new(),
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
@@ -171,6 +173,35 @@ fn run_configs_use_selected_interpreter_and_package_manager() {
|
||||
assert_eq!(configs[6].args, ["manage.py", "test", "polls"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn npm_config_runs_on_its_own_node_interpreter() {
|
||||
if cfg!(windows) {
|
||||
return; // Unix paths
|
||||
}
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.node.interpreter = "/opt/node22/bin/node".into();
|
||||
let mut own = run("npm", "npm", &["run", "dev"]);
|
||||
own.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into());
|
||||
let mut configs = vec![own, run("npm", "npm", &["run", "build"])];
|
||||
apply_to_run_configs(&mut configs, tmp.path(), &s);
|
||||
let first = |c: &RunConfig| c.env["PATH"].split(':').next().unwrap().to_owned();
|
||||
assert_eq!(first(&configs[0]), "/opt/node18/bin");
|
||||
assert_eq!(first(&configs[1]), "/opt/node22/bin", "the project's Node.js without one");
|
||||
// Saved without the derived PATH, keeping the choice itself.
|
||||
let saved = without_derived(&configs[0], tmp.path(), &s);
|
||||
assert!(!saved.env.contains_key("PATH"));
|
||||
assert_eq!(saved.extras.params["nodeInterpreter"], "/opt/node18/bin/node");
|
||||
// Chosen on a configuration loaded with the project's PATH: that PATH isn't kept either.
|
||||
let mut edited = configs[1].clone();
|
||||
edited.extras.params.insert("nodeInterpreter".into(), "/opt/node18/bin/node".into());
|
||||
let saved = without_derived(&edited, tmp.path(), &s);
|
||||
assert!(!saved.env.contains_key("PATH"));
|
||||
let mut reloaded = vec![saved];
|
||||
apply_to_run_configs(&mut reloaded, tmp.path(), &s);
|
||||
assert_eq!(first(&reloaded[0]), "/opt/node18/bin");
|
||||
}
|
||||
|
||||
fn fake_jdk(dir: &Path) {
|
||||
std::fs::create_dir_all(dir.join("bin")).unwrap();
|
||||
std::fs::write(dir.join("bin").join(exe("java")), "").unwrap();
|
||||
@@ -253,6 +284,129 @@ fn saved_maven_goal_configs_take_the_configured_launcher() {
|
||||
assert_eq!(configs[2].command, "./mvnw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_launch_refreshes_legacy_defaults_but_keeps_user_overrides() {
|
||||
use crate::apply::prepare_maven_run;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::write(tmp.path().join("pom.xml"), "<project/>").unwrap();
|
||||
let home = tmp.path().join("new-maven");
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
let launcher = home.join("bin").join(if cfg!(windows) { "mvn.cmd" } else { "mvn" });
|
||||
std::fs::write(&launcher, "").unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.home = home.to_string_lossy().into_owned();
|
||||
s.languages.java.maven.user_settings = "new-settings.xml".into();
|
||||
s.languages.java.maven.local_repository = "new-repo".into();
|
||||
let options = maven_options(tmp.path(), &s);
|
||||
|
||||
for id in [
|
||||
"maven:goal:pom.xml:test",
|
||||
"maven:test",
|
||||
"context:maven:test:src/test/java/ExampleTest.java",
|
||||
"context:target:context:maven:test:src/test/java/ExampleTest.java:ExampleTest#example",
|
||||
"workspace:abc:maven:test",
|
||||
"context:target:context:workspace:abc:maven:test:src/test/java/ExampleTest.java:ExampleTest#example",
|
||||
] {
|
||||
let mut config = run(
|
||||
"maven",
|
||||
"//?/UNC/server/old-maven/bin/mvn.cmd",
|
||||
&["-s", "old-settings.xml", "-Dmaven.repo.local=old-repo", "test", "-Dtest=ExampleTest#example"],
|
||||
);
|
||||
config.id = id.into();
|
||||
config.detected = !id.starts_with("maven:goal:");
|
||||
let original = config.clone();
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(config.command, launcher.to_string_lossy(), "{id}");
|
||||
assert_eq!(
|
||||
config.args,
|
||||
[options.clone(), vec!["test".into(), "-Dtest=ExampleTest#example".into()]].concat(),
|
||||
"{id}"
|
||||
);
|
||||
// Rerunning the stored configuration reads settings again, without changing the stored input.
|
||||
s.languages.java.maven.local_repository = "other-repo".into();
|
||||
let mut rerun = original;
|
||||
prepare_maven_run(&mut rerun, tmp.path(), &s);
|
||||
assert!(rerun.args.contains(&format!("-Dmaven.repo.local={}", tmp.path().join("other-repo").display())));
|
||||
s.languages.java.maven.local_repository = "new-repo".into();
|
||||
}
|
||||
|
||||
for args in [
|
||||
vec!["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"],
|
||||
vec!["--settings=own-settings.xml", "-D", "maven.repo.local=own-repo", "test"],
|
||||
] {
|
||||
let mut config = run("maven", "mvn", &args);
|
||||
config.detected = false;
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(config.command, launcher.to_string_lossy());
|
||||
assert_eq!(config.args, args);
|
||||
}
|
||||
|
||||
let mut explicit = run("maven", "/custom/mvn", &["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
|
||||
explicit.detected = false;
|
||||
prepare_maven_run(&mut explicit, tmp.path(), &s);
|
||||
assert_eq!(explicit.command, "/custom/mvn");
|
||||
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
|
||||
|
||||
// A gutter configuration derived from a user-edited default must keep that user's choices.
|
||||
let mut saved = explicit.clone();
|
||||
saved.id = "maven:test".into();
|
||||
ide_run::save_user_configs(tmp.path(), &[saved]).unwrap();
|
||||
explicit.id = "context:maven:test:src/test/java/ExampleTest.java".into();
|
||||
explicit.detected = true;
|
||||
prepare_maven_run(&mut explicit, tmp.path(), &s);
|
||||
assert_eq!(explicit.command, "/custom/mvn");
|
||||
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
|
||||
|
||||
std::fs::create_dir(tmp.path().join("child")).unwrap();
|
||||
std::fs::write(tmp.path().join("child/pom.xml"), "<project/>").unwrap();
|
||||
let mut nested = explicit.clone();
|
||||
nested.id = "maven:test:child".into();
|
||||
nested.detected = false;
|
||||
ide_run::save_user_configs(tmp.path(), &[nested]).unwrap();
|
||||
explicit.id = "context:maven:test:child:src/test/java/ExampleTest.java".into();
|
||||
prepare_maven_run(&mut explicit, tmp.path(), &s);
|
||||
assert_eq!(explicit.command, "/custom/mvn", "an edited child must not fall back to the parent's defaults");
|
||||
assert_eq!(explicit.args, ["-s", "own-settings.xml", "-Dmaven.repo.local=own-repo", "test"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_goal_keeps_explicit_settings_and_nested_wrapper() {
|
||||
use crate::apply::prepare_maven_run;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(tmp.path().join("child")).unwrap();
|
||||
let wrapper = tmp.path().join("child").join(if cfg!(windows) { "mvnw.cmd" } else { "mvnw" });
|
||||
std::fs::write(&wrapper, "").unwrap();
|
||||
let mut config = run("maven", "old-maven", &["-s", "old-settings.xml", "-s", "own-settings.xml", "test"]);
|
||||
config.id = "maven:goal:child/pom.xml:-s own-settings.xml test".into();
|
||||
config.cwd = Some("child".into());
|
||||
config.detected = false;
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.user_settings = "new-settings.xml".into();
|
||||
prepare_maven_run(&mut config, tmp.path(), &s);
|
||||
assert_eq!(config.command, wrapper.to_string_lossy());
|
||||
assert_eq!(config.args, ["-s", "own-settings.xml", "test"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persisting_a_maven_goal_keeps_explicit_options_equal_to_the_defaults() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut s = Settings::default();
|
||||
s.languages.java.maven.user_settings = tmp.path().join("settings.xml").to_string_lossy().into_owned();
|
||||
s.languages.java.maven.local_repository = tmp.path().join("repo").to_string_lossy().into_owned();
|
||||
let options = maven_options(tmp.path(), &s);
|
||||
for goals in [vec![options[2].clone(), "test".into()], [options.clone(), vec!["test".into()]].concat()] {
|
||||
let mut config = run("maven", "mvn", &[]);
|
||||
config.id = format!("maven:goal:pom.xml:{}", goals.join(" "));
|
||||
config.detected = false;
|
||||
config.args = goals.clone();
|
||||
apply_to_run_configs(std::slice::from_mut(&mut config), tmp.path(), &s);
|
||||
let kept = without_derived(&config, tmp.path(), &s);
|
||||
assert_eq!(kept.args, goals);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_server_settings_disable_add_and_override() {
|
||||
use crate::apply::{language_servers, server_launch_overrides, server_protocol_overrides, with_custom_servers};
|
||||
@@ -329,6 +483,26 @@ fn language_server_settings_disable_add_and_override() {
|
||||
let init = serde_json::to_value(ra.initialization_options.unwrap()).unwrap();
|
||||
assert_eq!(init, json!({ "checkOnSave": false, "check": { "command": "clippy" } }));
|
||||
assert_eq!(ra.settings, Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } })));
|
||||
assert!(ra.features.is_empty() && ra.code_action_kinds.is_empty(), "no setting keeps the definition's");
|
||||
|
||||
// `features` / `codeActionKinds` replace the definition's; empty lifts them.
|
||||
let mut pylsp = server("pylsp");
|
||||
pylsp.features = vec!["code-action".into()];
|
||||
pylsp.code_action_kinds = vec!["refactor".into()];
|
||||
s.language_servers.insert(
|
||||
"pylsp".into(),
|
||||
LanguageServerSettings {
|
||||
features: Some(vec!["code-action".into(), "hover".into()]),
|
||||
code_action_kinds: Some(vec![]),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
server_protocol_overrides(&mut pylsp, &s);
|
||||
assert_eq!(pylsp.features, ["code-action", "hover"]);
|
||||
assert!(pylsp.code_action_kinds.is_empty());
|
||||
let parsed: LanguageServerSettings =
|
||||
serde_json::from_value(json!({ "features": ["code-action"], "codeActionKinds": ["refactor"] })).unwrap();
|
||||
assert_eq!(parsed.code_action_kinds, Some(vec!["refactor".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1212,6 +1386,8 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
|
||||
s.languages.java.jdk = root.join("jdk17").to_string_lossy().into_owned();
|
||||
touch("maven/bin/mvn");
|
||||
s.languages.java.maven.home = root.join("maven").to_string_lossy().into_owned();
|
||||
s.languages.java.maven.user_settings = "/m2/settings.xml".into();
|
||||
s.languages.java.maven.local_repository = "/m2/repo".into();
|
||||
s.languages.ruby.interpreter = touch("ruby/bin/ruby");
|
||||
touch("ruby/bin/bundle");
|
||||
s.tools.insert("godot".into(), ide_core::settings::ToolSettings { path: touch("Godot/godot") });
|
||||
@@ -1265,4 +1441,28 @@ fn saving_a_loaded_run_configuration_drops_what_settings_derived() {
|
||||
let ruby2 = root.join("ruby2/bin");
|
||||
assert_eq!(next.command, ruby2.join("ruby").to_string_lossy());
|
||||
assert!(next.env["PATH"].starts_with(&*ruby2.to_string_lossy()), "{}", next.env["PATH"]);
|
||||
|
||||
// A Maven configuration (a detected one narrowed to a test, a tool window goal) follows a
|
||||
// later Maven home, settings file, local repository and JDK; options it sets itself stay.
|
||||
for detected in [true, false] {
|
||||
let mut goal = saved("maven", "mvn", &["test", "-Dtest=AppTest"], &[]);
|
||||
goal.detected = detected;
|
||||
let mut own = saved("maven", "mvn", &["-s", "/mine.xml", "test"], &[]);
|
||||
own.detected = detected;
|
||||
let mut loaded = [goal, own];
|
||||
apply_to_run_configs(&mut loaded, root, &s);
|
||||
let mut next = loaded.map(|c| without_derived(&c, root, &s));
|
||||
touch("maven2/bin/mvn");
|
||||
fake_jdk(&root.join("jdk21"));
|
||||
let mut s2 = s.clone();
|
||||
s2.languages.java.maven.home = root.join("maven2").to_string_lossy().into_owned();
|
||||
s2.languages.java.maven.user_settings = "/m3/settings.xml".into();
|
||||
s2.languages.java.maven.local_repository = "/m3/repo".into();
|
||||
s2.languages.java.jdk = root.join("jdk21").to_string_lossy().into_owned();
|
||||
apply_to_run_configs(&mut next, root, &s2);
|
||||
assert_eq!(next[0].command, root.join("maven2/bin/mvn").to_string_lossy());
|
||||
assert_eq!(next[0].args, ["-s", "/m3/settings.xml", "-Dmaven.repo.local=/m3/repo", "test", "-Dtest=AppTest"]);
|
||||
assert!(next[0].env["JAVA_HOME"].ends_with("jdk21"), "{}", next[0].env["JAVA_HOME"]);
|
||||
assert_eq!(next[1].args, ["-Dmaven.repo.local=/m3/repo", "-s", "/mine.xml", "test"]);
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,19 @@ impl Tunnel {
|
||||
|
||||
/// [`Tunnel::open`] without the reachability check (the target may start later).
|
||||
pub async fn listen(session: Arc<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
Self::listen_on(session, None, host, port).await
|
||||
}
|
||||
|
||||
/// [`Tunnel::listen`] on local port `preferred` when it is free, else on any free port
|
||||
/// (a forwarded remote port keeps its number locally when it can, like VS Code's Ports).
|
||||
pub async fn listen_on(session: Arc<Session>, preferred: Option<u16>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
let listener = match preferred {
|
||||
Some(p) => match TcpListener::bind(("127.0.0.1", p)).await {
|
||||
Ok(l) => l,
|
||||
Err(_) => TcpListener::bind(("127.0.0.1", 0)).await?,
|
||||
},
|
||||
None => TcpListener::bind(("127.0.0.1", 0)).await?,
|
||||
};
|
||||
let local = listener.local_addr()?.port();
|
||||
let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port));
|
||||
Ok(Tunnel { port: local, session, accept })
|
||||
|
||||
@@ -217,6 +217,21 @@ async fn password_authentication_and_forwarding() {
|
||||
assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forwarding_keeps_the_port_number_when_it_is_free() {
|
||||
let env = env().await;
|
||||
let target = env.target(SshAuth::Password);
|
||||
let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap());
|
||||
let free = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let tunnel = Tunnel::listen_on(session.clone(), Some(free), "127.0.0.1", env.echo_port).await.unwrap();
|
||||
assert_eq!(tunnel.port(), free);
|
||||
round_trip(&tunnel).await;
|
||||
// Taken here: another free port.
|
||||
let other = Tunnel::listen_on(session, Some(free), "127.0.0.1", env.echo_port).await.unwrap();
|
||||
assert_ne!(other.port(), free);
|
||||
round_trip(&other).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_authentication_with_and_without_passphrase() {
|
||||
let env = env().await;
|
||||
|
||||
+102
-14
@@ -116,6 +116,11 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
// Walks in (roughly) commit time order can stop once they are well past `since`.
|
||||
let time_ordered = range.is_none() && (q.order == LogOrder::Date || path_walk);
|
||||
let mut older_run = 0usize;
|
||||
// A user or text filter hides commits within the history: the shown ones are linked to their
|
||||
// nearest shown ancestors instead (dashed past hidden ones), as IDEA's filtered graph does.
|
||||
// A path walk already links commits that way.
|
||||
let mut links = (!path_walk && (!authors.is_empty() || text.is_some())).then(FilteredLinks::default);
|
||||
let mut extra = 0usize;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for item in walk {
|
||||
@@ -123,8 +128,17 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
if q.first_parent {
|
||||
parents.truncate(1);
|
||||
}
|
||||
if out.len() >= limit {
|
||||
// Full: walk on only to link the last commits to their shown ancestors.
|
||||
let Some(l) = links.as_ref() else { break };
|
||||
extra += 1;
|
||||
if l.is_empty() || extra > LINK_LIMIT {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let commit = repo.find_commit(id).map_err(gx)?;
|
||||
let commit_time = commit.time().map(|t| t.seconds).unwrap_or(0);
|
||||
let mut shown = true;
|
||||
if shape.is_active() {
|
||||
let merge = commit.parent_ids().nth(1).is_some();
|
||||
if shape.since.is_some_and(|s| commit_time < s) {
|
||||
@@ -132,33 +146,41 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
if time_ordered && older_run >= OLDER_RUN_LIMIT {
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
shown = false;
|
||||
} else {
|
||||
older_run = 0;
|
||||
if !shape.shows(merge, commit_time) {
|
||||
shown = false;
|
||||
} else if links.is_none() {
|
||||
parents = shape.rewrite_parents(&repo, &parents)?;
|
||||
}
|
||||
}
|
||||
older_run = 0;
|
||||
if !shape.shows(merge, commit_time) {
|
||||
continue;
|
||||
}
|
||||
parents = shape.rewrite_parents(&repo, &parents)?;
|
||||
}
|
||||
let sig = commit.author().map_err(gx)?;
|
||||
let name = to_string(sig.name.trim().as_bstr());
|
||||
let email = to_string(sig.email.trim().as_bstr());
|
||||
if !authors.is_empty() {
|
||||
if shown && !authors.is_empty() {
|
||||
let (name, email) = (name.to_lowercase(), email.to_lowercase());
|
||||
if !authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str())) {
|
||||
continue;
|
||||
}
|
||||
shown = authors.iter().any(|a| name.contains(a.as_str()) || email.contains(a.as_str()));
|
||||
}
|
||||
let message = to_string(commit.message_raw_sloppy());
|
||||
if let Some(filter) = text.as_ref()
|
||||
&& !filter.matches(&message, &id)
|
||||
{
|
||||
if shown && let Some(filter) = text.as_ref() {
|
||||
shown = filter.matches(&message, &id);
|
||||
}
|
||||
let listed = shown && to_skip == 0 && out.len() < limit;
|
||||
if let Some(l) = links.as_mut() {
|
||||
l.visit(id, &parents, shown, listed.then_some(out.len()));
|
||||
}
|
||||
if !shown {
|
||||
continue;
|
||||
}
|
||||
if to_skip > 0 {
|
||||
to_skip -= 1;
|
||||
continue;
|
||||
}
|
||||
if !listed {
|
||||
continue;
|
||||
}
|
||||
let time_ms = sig.time().map(|t| t.seconds as f64 * 1000.0).unwrap_or(0.0);
|
||||
let summary = message.lines().find(|l| !l.trim().is_empty()).unwrap_or("").trim().to_string();
|
||||
let hex = id.to_string();
|
||||
@@ -173,14 +195,80 @@ pub(crate) fn log(r: &Repo, q: &LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
message: message.trim_end().to_string(),
|
||||
refs: decorations.get(&id).cloned().unwrap_or_default(),
|
||||
commit_time_ms: commit_time as f64 * 1000.0,
|
||||
dashed: Vec::new(),
|
||||
});
|
||||
if out.len() >= limit {
|
||||
if out.len() >= limit && links.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(l) = links {
|
||||
l.apply(&mut out);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Commits walked past a full page to link its last commits to their shown ancestors.
|
||||
const LINK_LIMIT: usize = 20_000;
|
||||
|
||||
/// The links of a filtered log: each listed commit's nearest shown ancestors. The walk visits
|
||||
/// children before parents, so a listed commit waits on its parents; a hidden commit passes
|
||||
/// the waits on to its own parents, and a shown one takes them.
|
||||
#[derive(Default)]
|
||||
struct FilteredLinks {
|
||||
/// Commit → listed commits (by index) waiting on it, and whether the way passed a hidden one.
|
||||
waiting: HashMap<ObjectId, Vec<(usize, bool)>>,
|
||||
/// Per listed commit: its shown ancestors in the order found, and whether each link is dashed.
|
||||
found: HashMap<usize, Vec<(ObjectId, bool)>>,
|
||||
}
|
||||
|
||||
impl FilteredLinks {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.waiting.is_empty()
|
||||
}
|
||||
|
||||
fn visit(&mut self, id: ObjectId, parents: &[ObjectId], shown: bool, listed: Option<usize>) {
|
||||
let waits = self.waiting.remove(&id).unwrap_or_default();
|
||||
if shown {
|
||||
for (child, dashed) in waits {
|
||||
let links = self.found.entry(child).or_default();
|
||||
match links.iter_mut().find(|(p, _)| *p == id) {
|
||||
Some(link) => link.1 &= dashed,
|
||||
None => links.push((id, dashed)),
|
||||
}
|
||||
}
|
||||
if let Some(index) = listed {
|
||||
for p in parents {
|
||||
Self::wait(&mut self.waiting, *p, index, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for p in parents {
|
||||
for (child, _) in &waits {
|
||||
Self::wait(&mut self.waiting, *p, *child, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wait(waiting: &mut HashMap<ObjectId, Vec<(usize, bool)>>, on: ObjectId, child: usize, dashed: bool) {
|
||||
let list = waiting.entry(on).or_default();
|
||||
match list.iter_mut().find(|(c, _)| *c == child) {
|
||||
Some(w) => w.1 &= dashed,
|
||||
None => list.push((child, dashed)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces the listed commits' parents by their shown ancestors (none found: none).
|
||||
fn apply(mut self, out: &mut [Commit]) {
|
||||
for (index, commit) in out.iter_mut().enumerate() {
|
||||
let links = self.found.remove(&index).unwrap_or_default();
|
||||
commit.parents = links.iter().map(|(id, _)| id.to_string()).collect();
|
||||
commit.dashed =
|
||||
if links.iter().any(|(_, d)| *d) { links.iter().map(|(_, d)| *d).collect() } else { Vec::new() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Consecutive commits older than `since` after which a time-ordered walk stops.
|
||||
const OLDER_RUN_LIMIT: usize = 64;
|
||||
|
||||
|
||||
@@ -92,6 +92,11 @@ pub struct Commit {
|
||||
/// Committer time (Log › View Options › Commit Timestamp).
|
||||
#[serde(default)]
|
||||
pub commit_time_ms: f64,
|
||||
/// Per parent, under a user or text filter: the link passes commits the filter hides
|
||||
/// (`parents` then name the nearest shown ancestors). Empty when no parent is rewritten.
|
||||
#[serde(default)]
|
||||
#[specta(optional)]
|
||||
pub dashed: Vec<bool>,
|
||||
}
|
||||
|
||||
/// Order of the Git Log (Graph Options › Sort).
|
||||
|
||||
+24
-13
@@ -13,16 +13,27 @@ pub(crate) fn gx(e: impl std::fmt::Display) -> IdeError {
|
||||
|
||||
pub(crate) struct Cache {
|
||||
repo: gix::ThreadSafeRepository,
|
||||
config_files: [PathBuf; 3],
|
||||
config_stamp: Vec<Option<SystemTime>>,
|
||||
files: [PathBuf; 4],
|
||||
stamp: Vec<Option<(SystemTime, u64)>>,
|
||||
}
|
||||
|
||||
fn config_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 3] {
|
||||
[common_dir.join("config"), git_dir.join("config.worktree"), common_dir.join("config.worktree")]
|
||||
fn cache_files(git_dir: &Path, common_dir: &Path) -> [PathBuf; 4] {
|
||||
[
|
||||
common_dir.join("config"),
|
||||
git_dir.join("config.worktree"),
|
||||
common_dir.join("config.worktree"),
|
||||
common_dir.join("packed-refs"),
|
||||
]
|
||||
}
|
||||
|
||||
fn stamp(files: &[PathBuf]) -> Vec<Option<SystemTime>> {
|
||||
files.iter().map(|f| std::fs::metadata(f).and_then(|m| m.modified()).ok()).collect()
|
||||
fn stamp(files: &[PathBuf]) -> Vec<Option<(SystemTime, u64)>> {
|
||||
files
|
||||
.iter()
|
||||
.map(|f| {
|
||||
let metadata = std::fs::metadata(f).ok()?;
|
||||
Some((metadata.modified().ok()?, metadata.len()))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
@@ -45,8 +56,8 @@ pub(crate) fn discover(path: &Path) -> IdeResult<Option<Repo>> {
|
||||
let root = absolute(workdir)?;
|
||||
let git_dir = absolute(repo.git_dir())?;
|
||||
let common_dir = absolute(repo.common_dir())?;
|
||||
let files = config_files(&git_dir, &common_dir);
|
||||
let cache = Cache { config_stamp: stamp(&files), config_files: files, repo: ts };
|
||||
let files = cache_files(&git_dir, &common_dir);
|
||||
let cache = Cache { stamp: stamp(&files), files, repo: ts };
|
||||
Ok(Some(Repo {
|
||||
execution_allowed: std::sync::atomic::AtomicBool::new(true),
|
||||
root,
|
||||
@@ -73,8 +84,8 @@ pub(crate) fn absolute(p: &Path) -> IdeResult<PathBuf> {
|
||||
}
|
||||
|
||||
impl Repo {
|
||||
/// A thread-local gitoxide handle; reopened when the git configuration changed
|
||||
/// on disk (e.g. after `push --set-upstream` or `git config` in a terminal).
|
||||
/// A thread-local gitoxide handle; reopened when configuration or packed refs change.
|
||||
/// gitoxide's packed-ref snapshot only reloads for a newer mtime, which a replacement may preserve.
|
||||
pub(crate) fn gix(&self) -> IdeResult<gix::Repository> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
if !self.execution_allowed.load(std::sync::atomic::Ordering::Acquire) {
|
||||
@@ -82,12 +93,12 @@ impl Repo {
|
||||
.map_err(gx);
|
||||
}
|
||||
let mut cache = self.inner.lock().unwrap_or_else(|p| p.into_inner());
|
||||
let now = stamp(&cache.config_files);
|
||||
if now != cache.config_stamp {
|
||||
let now = stamp(&cache.files);
|
||||
if now != cache.stamp {
|
||||
match gix::ThreadSafeRepository::open(&self.git_dir) {
|
||||
Ok(fresh) => {
|
||||
cache.repo = fresh;
|
||||
cache.config_stamp = now;
|
||||
cache.stamp = now;
|
||||
}
|
||||
Err(e) => tracing::warn!("reopening repository failed: {e}"),
|
||||
}
|
||||
|
||||
@@ -178,3 +178,43 @@ fn remotes_and_worktrees_are_managed() {
|
||||
repo.remove_worktree(&wt, false).unwrap();
|
||||
assert_eq!(repo.worktrees().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_user_filter_links_shown_commits_past_hidden_ones() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
git(dir, &["init", "-q"], None);
|
||||
let by = |who: &str, file: &str, msg: &str, time: i64| {
|
||||
std::fs::write(dir.join(file), msg).unwrap();
|
||||
git(dir, &["add", "-A"], None);
|
||||
let email = format!("user.email={who}@example.com");
|
||||
git(dir, &["-c", &email, "commit", "-q", "-m", msg], Some(time));
|
||||
git(dir, &["rev-parse", "HEAD"], None)
|
||||
};
|
||||
let m1 = by("me", "a", "m1", 1_000);
|
||||
let o1 = by("other", "b", "o1", 2_000);
|
||||
let m2 = by("me", "c", "m2", 3_000);
|
||||
let m3 = by("me", "d", "m3", 4_000);
|
||||
let _o2 = by("other", "e", "o2", 5_000);
|
||||
let repo = Repo::discover(dir).unwrap().unwrap();
|
||||
|
||||
let log = repo.log(&LogQuery { author: Some("me@".into()), ..Default::default() }).unwrap();
|
||||
let links: Vec<(&str, Vec<&str>, Vec<bool>)> = log
|
||||
.iter()
|
||||
.map(|c| (c.summary.as_str(), c.parents.iter().map(String::as_str).collect(), c.dashed.clone()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
links,
|
||||
[("m3", vec![m2.as_str()], vec![]), ("m2", vec![m1.as_str()], vec![true]), ("m1", vec![], vec![]),],
|
||||
"m2's parent o1 is hidden: m2 links to m1, dashed"
|
||||
);
|
||||
assert!(!log.iter().any(|c| c.parents.contains(&o1)));
|
||||
|
||||
// A page ends at m2: walking on still links it to m1.
|
||||
let page = repo.log(&LogQuery { author: Some("me@".into()), limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(page.len(), 2);
|
||||
assert_eq!(page[1].parents, std::slice::from_ref(&m1));
|
||||
let next = repo.log(&LogQuery { author: Some("me@".into()), skip: 2, limit: 2, ..Default::default() }).unwrap();
|
||||
assert_eq!(next.iter().map(|c| c.id.clone()).collect::<Vec<_>>(), std::slice::from_ref(&m1));
|
||||
let _ = m3;
|
||||
}
|
||||
@@ -682,6 +682,8 @@ fn file_at_in_a_blobless_clone() {
|
||||
fx.write("f.txt", "v2\n");
|
||||
fx.commit_all("v2");
|
||||
fx.git(&["config", "uploadpack.allowFilter", "true"]);
|
||||
// Git refuses a clone source with a local hooks-path override.
|
||||
fx.git(&["config", "--unset", "core.hooksPath"]);
|
||||
let clone = fx.dir.parent().unwrap().join("clone");
|
||||
let url = format!("file://{}", fx.dir.display());
|
||||
git_in(fx.dir.parent().unwrap(), &["clone", "-q", "--filter=blob:none", &url, "clone"]);
|
||||
@@ -1257,6 +1259,62 @@ fn errors_carry_git_messages() {
|
||||
assert!(repo.stash_apply(5, false, false).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_refs_reload_without_a_newer_modification_time() {
|
||||
for keep_time in [false, true] {
|
||||
for linked in [false, true] {
|
||||
let fx = Fixture::new();
|
||||
fx.write("file.txt", "one\n");
|
||||
let first = fx.commit_all("one");
|
||||
fx.git(&["branch", "-m", "master"]);
|
||||
let root = if linked {
|
||||
let path = fx._tmp.path().join("linked");
|
||||
fx.git(&["worktree", "add", "-qb", "agent/check", path.to_str().unwrap()]);
|
||||
path
|
||||
} else {
|
||||
fx.dir.clone()
|
||||
};
|
||||
let packed = fx.path(".git/packed-refs");
|
||||
std::fs::write(
|
||||
&packed,
|
||||
format!("# pack-refs with: peeled fully-peeled sorted \n{first} refs/remotes/origin/master\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let set_modified = |time| {
|
||||
std::fs::OpenOptions::new().write(true).open(&packed).unwrap().set_modified(time).unwrap();
|
||||
};
|
||||
set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(3600));
|
||||
let original_time = std::fs::metadata(&packed).unwrap().modified().unwrap();
|
||||
let original_len = std::fs::metadata(&packed).unwrap().len();
|
||||
let repo = Repo::discover(&root).unwrap().unwrap();
|
||||
let query = LogQuery { branches: vec!["HEAD".into()], limit: 1, ..Default::default() };
|
||||
assert_eq!(repo.log(&query).unwrap()[0].id, first);
|
||||
|
||||
std::fs::write(root.join("file.txt"), "two\n").unwrap();
|
||||
git_in(&root, &["add", "file.txt"]);
|
||||
git_in(&root, &["commit", "-qm", "two"]);
|
||||
let second = git_in(&root, &["rev-parse", "HEAD"]);
|
||||
fx.git(&["pack-refs", "--all", "--prune"]);
|
||||
set_modified(if keep_time { original_time } else { original_time - std::time::Duration::from_secs(60) });
|
||||
assert_ne!(std::fs::metadata(&packed).unwrap().len(), original_len);
|
||||
let branch = if linked { "agent/check" } else { "master" };
|
||||
assert!(!fx.path(&format!(".git/refs/heads/{branch}")).exists());
|
||||
for revision in ["HEAD", "@", branch] {
|
||||
let q = LogQuery { branches: vec![revision.into()], ..query.clone() };
|
||||
let rows = repo.log(&q).unwrap_or_else(|error| {
|
||||
panic!("linked={linked}, keep_time={keep_time}, revision={revision}: {error}")
|
||||
});
|
||||
assert_eq!(rows[0].id, second);
|
||||
}
|
||||
assert_eq!(repo.head().unwrap().1.as_deref(), Some(second.as_str()));
|
||||
assert_eq!(
|
||||
repo.file_at(Path::new("file.txt"), Some("HEAD"), encoding_rs::UTF_8).unwrap().as_deref(),
|
||||
Some("two\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_reflects_external_changes_without_reopen() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
+23
-3
@@ -132,9 +132,29 @@ and credit the original authors in `authors` and `description`.
|
||||
|
||||
### From IntelliJ IDEA (automatic)
|
||||
|
||||
To use a theme you already have in IntelliJ, 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`
|
||||
(macOS), `%APPDATA%\JetBrains` (Windows), `~/.config/JetBrains` and
|
||||
`~/.local/share/JetBrains` (Linux).
|
||||
- **Import IntelliJ Theme from File…** takes a theme plugin downloaded from the
|
||||
JetBrains Marketplace (`.jar`, or a `.zip` with the plugin's `lib/*.jar`) or a
|
||||
`.theme.json` (its `editorScheme` file is read from the same folder).
|
||||
|
||||
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`,
|
||||
`plugins.importInstalledIntellijTheme`). The conversion is the one described
|
||||
below.
|
||||
|
||||
`scripts/theme/import-intellij-theme.ts` converts an IntelliJ `.theme.json`,
|
||||
and optionally its `.icls` editor scheme, into a ready-to-install plugin. It
|
||||
has no dependencies beyond Node 22.
|
||||
and optionally its `.icls` editor scheme, into a plugin folder you can edit
|
||||
before installing it — the way to port a theme for publishing. It has no
|
||||
dependencies beyond Node 22.
|
||||
|
||||
```sh
|
||||
pnpm theme:import-intellij path/to/MyTheme.theme.json path/to/MyTheme.icls --out ~/my-theme --author "Original Author"
|
||||
@@ -179,7 +199,7 @@ ship no `.icls` at all keep the built-in syntax colours.
|
||||
| `--bg-toggle-*` | `ToggleButton.onBackground`, `.offBackground`, `.buttonColor` |
|
||||
| `--bg-feedback-*`, `--border-feedback-*` | `Banner.infoBackground`, `Banner.errorBorderColor`, … |
|
||||
|
||||
The full table is `UI_KEYS` in [`scripts/theme/intellij.ts`](../../scripts/theme/intellij.ts).
|
||||
The full table is `UI_KEYS` in [`src/features/plugins/intellijTheme.ts`](../../src/features/plugins/intellijTheme.ts).
|
||||
|
||||
#### `.icls` → editor scheme
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("configures a ClickHouse HTTP source and keeps transaction controls unavailable", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await page.evaluate(async () => {
|
||||
const storePath = "/src/features/database/store.ts";
|
||||
const bindingsPath = "/src/bindings.ts";
|
||||
const { useDatabase } = await import(storePath);
|
||||
const { commands } = await import(bindingsPath);
|
||||
commands.dbTest = async (source: unknown) => {
|
||||
document.documentElement.dataset.testedSource = JSON.stringify(source);
|
||||
return { status: "ok", data: "ClickHouse" };
|
||||
};
|
||||
useDatabase.setState({ dialog: { kind: "source", source: null } });
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "New Data Source", exact: true });
|
||||
await dialog.getByRole("button", { name: "Data source type", exact: true }).click();
|
||||
await page.getByRole("option", { name: "ClickHouse", exact: true }).click();
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("8123");
|
||||
await expect(dialog.locator("#db-user")).toHaveValue("default");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("default");
|
||||
await expect(dialog.locator("#db-url")).toHaveValue("http://localhost:8123/default");
|
||||
await dialog.locator("#db-url").fill("https://[email protected]:8443/analytics");
|
||||
await expect(dialog.locator("#db-host")).toHaveValue("db.example");
|
||||
await expect(dialog.locator("#db-port")).toHaveValue("8443");
|
||||
await expect(dialog.locator("#db-user")).toHaveValue("reader");
|
||||
await expect(dialog.locator("#db-database")).toHaveValue("analytics");
|
||||
await dialog.getByRole("button", { name: "Test Connection", exact: true }).click();
|
||||
await expect(dialog.getByRole("status")).toHaveText("Succeeded: ClickHouse");
|
||||
expect(await page.evaluate(() => JSON.parse(document.documentElement.dataset.testedSource!))).toMatchObject({
|
||||
driver: "clickhouse",
|
||||
host: "db.example",
|
||||
port: 8443,
|
||||
user: "reader",
|
||||
database: "analytics",
|
||||
ssl: "verifyFull",
|
||||
});
|
||||
await dialog.screenshot({ path: info.outputPath("clickhouse-source.png") });
|
||||
await dialog.getByRole("tab", { name: "Options", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Transaction control", exact: true })).toBeDisabled();
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
/** Deployment diff over an in-memory server: no SSH or filesystem writes. */
|
||||
import { test, expect } from "./fixtures";
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
test("native deployment entry fills its own window and exposes minimize and maximize controls", async ({
|
||||
page,
|
||||
}, info) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await page.route("**/deployment-window-test", (route) =>
|
||||
route.fulfill({
|
||||
contentType: "text/html",
|
||||
body: '<!doctype html><html><head></head><body><div id="root"></div></body></html>',
|
||||
}),
|
||||
);
|
||||
await page.goto("/deployment-window-test");
|
||||
await page.evaluate(async () => {
|
||||
const refreshModule = "/@react-refresh";
|
||||
const { default: refresh } = await import(/* @vite-ignore */ refreshModule);
|
||||
refresh.injectIntoGlobalHook(window);
|
||||
Object.assign(window, {
|
||||
$RefreshReg$: () => {},
|
||||
$RefreshSig$: () => (type: unknown) => type,
|
||||
__vite_plugin_react_preamble_installed__: true,
|
||||
});
|
||||
const mocksModule = "/node_modules/@tauri-apps/api/mocks.js";
|
||||
const { mockIPC, mockWindows } = await import(/* @vite-ignore */ mocksModule);
|
||||
const calls: string[] = [];
|
||||
(window as unknown as { deploymentCalls: string[] }).deploymentCalls = calls;
|
||||
const settings = {
|
||||
appearance: { theme: "dark", syncWithOs: false },
|
||||
deployment: { servers: [{ id: "staging", name: "Staging", kind: "local", rootPath: "/srv/app" }] },
|
||||
};
|
||||
let launch = true;
|
||||
const local = "<?php\nclass Perfil {\n public $name = 'Local';\n}\n";
|
||||
const remote = "<?php\nclass Perfil {\n public $name = 'Remote';\n}\n";
|
||||
mockWindows("deployment-main", "main");
|
||||
mockIPC(
|
||||
(command: string, args: Record<string, unknown>) => {
|
||||
calls.push(command);
|
||||
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) throw new Error("Unexpected write");
|
||||
if (command === "app_info")
|
||||
return { platform: "windows", version: "test", configDir: "/config", wayland: false };
|
||||
if (command === "settings_get") return settings;
|
||||
if (command === "project_current") return { root: "/second", name: "Second", settings };
|
||||
if (command === "window_take_launch") {
|
||||
if (!launch) return null;
|
||||
launch = false;
|
||||
return { deployment: { server: "staging", paths: ["/second/perfil.php"] } };
|
||||
}
|
||||
if (command === "deploy_coverage") return [{ server: "staging", paths: ["/second/perfil.php"], skipped: [] }];
|
||||
if (command === "deploy_compare")
|
||||
return [
|
||||
{
|
||||
path: "perfil.php",
|
||||
remotePath: "/srv/app/perfil.php",
|
||||
local: { size: local.length, mtime: 1700000100 },
|
||||
remote: { size: remote.length, mtime: 1700000000 },
|
||||
status: "localNewer",
|
||||
},
|
||||
];
|
||||
if (command === "remote_open") return "/snapshot/perfil.php";
|
||||
if (command === "fs_probe") return { size: local.length, binary: false, tooLarge: false };
|
||||
if (command === "fs_read_text") return String(args.path).startsWith("/snapshot/") ? remote : local;
|
||||
if (command === "doc_open") throw new Error("Read-only test fixture");
|
||||
if (command === "languages") return [];
|
||||
if (command === "plugin:window|is_maximized" || command === "plugin:window|is_fullscreen") return false;
|
||||
return null;
|
||||
},
|
||||
{ shouldMockEvents: true },
|
||||
);
|
||||
const entry = "/src/main.tsx";
|
||||
await import(/* @vite-ignore */ entry);
|
||||
});
|
||||
const comparison = page.getByRole("region", { name: "Diff Between 'local files' and 'files on Staging'" });
|
||||
await expect(comparison.getByRole("grid", { name: "Files to synchronize" })).toBeVisible();
|
||||
await expect(comparison.getByRole("region", { name: "perfil.php" }).getByRole("textbox")).toHaveCount(2);
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(page.getByRole("tree", { name: "Project" })).toHaveCount(0);
|
||||
const frame = await comparison.boundingBox();
|
||||
expect(frame!.y).toBe(0);
|
||||
expect(frame!.height).toBe(page.viewportSize()!.height);
|
||||
await expect(comparison.getByRole("button", { name: "Synchronize All", exact: true })).toBeInViewport();
|
||||
await expect(comparison.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeVisible();
|
||||
await comparison.getByRole("button", { name: "Minimize", exact: true }).click();
|
||||
await comparison.getByRole("button", { name: "Maximize", exact: true }).click();
|
||||
// The caption buttons reach the window API asynchronously.
|
||||
const calls = () => page.evaluate(() => (window as unknown as { deploymentCalls: string[] }).deploymentCalls);
|
||||
await expect.poll(calls).toContain("plugin:window|minimize");
|
||||
await expect.poll(calls).toContain("plugin:window|toggle_maximize");
|
||||
expect(errors).toEqual([]);
|
||||
await page.screenshot({ path: info.outputPath("deployment-native-window.png") });
|
||||
});
|
||||
|
||||
async function showComparison(page: Page) {
|
||||
await page.evaluate(async () => {
|
||||
const projectModule = "/src/platform/project.ts";
|
||||
const settingsModule = "/src/platform/remoteSettings.ts";
|
||||
const stateModule = "/src/features/deployment/store.ts";
|
||||
const routeModule = "/src/platform/tauriCore.ts";
|
||||
const backendModule = "/src/editor/core/backend.ts";
|
||||
const [{ useProject }, { DEFAULT_DEPLOYMENT_SERVER }, { useDeployment }, { setInvokeRoute }, { docBackend }] =
|
||||
await Promise.all([
|
||||
import(/* @vite-ignore */ projectModule),
|
||||
import(/* @vite-ignore */ settingsModule),
|
||||
import(/* @vite-ignore */ stateModule),
|
||||
import(/* @vite-ignore */ routeModule),
|
||||
import(/* @vite-ignore */ backendModule),
|
||||
]);
|
||||
const local: Record<string, string> = {
|
||||
"app.js": "export const version = 'local';\n",
|
||||
"perfil.php":
|
||||
"<?php\nclass Perfil extends AppModel {\n var $name = 'Perfil';\n var $useTable = 'app_perfiles';\n var $displayField = 'descripcion';\n\n function getPerfilesValidos($model) {\n return $this->find('list', [\n 'fields' => [$this->alias . '.id', $this->alias . '.descripcion'],\n 'order' => $this->alias . '.descripcion ASC',\n ]);\n }\n}\n",
|
||||
"hidden.php": "alpha",
|
||||
"same.js": "same",
|
||||
};
|
||||
const remote: Record<string, string> = {
|
||||
"app.js": "export const version = 'deployed';\n",
|
||||
"perfil.php":
|
||||
"<?php\nclass Perfil extends AppModel {\n var $name = 'Perfil';\n var $useTable = 'app_perfiles';\n}\n",
|
||||
"hidden.php": "bravo",
|
||||
"same.js": "same",
|
||||
};
|
||||
const root = useProject.getState().project.root;
|
||||
for (const [path, content] of Object.entries(local)) docBackend().files.set(`${root}/${path}`, content);
|
||||
const writes: string[] = [];
|
||||
(window as unknown as { deploymentWrites: string[] }).deploymentWrites = writes;
|
||||
const syncState = { enabled: false, transfers: [] as { path: string; action: string }[][] };
|
||||
(window as unknown as { deploymentSyncState: typeof syncState }).deploymentSyncState = syncState;
|
||||
const text = (path: string) => (path.startsWith("/snapshot/") ? remote : local)[path.split("/").at(-1)!] ?? "";
|
||||
setInvokeRoute((command: string, args: Record<string, unknown>) => {
|
||||
const path = String(args?.path ?? "");
|
||||
if (command === "remote_open") return Promise.resolve(`/snapshot/${path.split("/").at(-1)}`);
|
||||
if (command === "fs_probe") return Promise.resolve({ size: text(path).length, binary: false, tooLarge: false });
|
||||
if (command === "fs_read_text") return Promise.resolve(text(path));
|
||||
if (command === "fs_read_range") {
|
||||
const content = text(path);
|
||||
return Promise.resolve({
|
||||
total: content.length,
|
||||
offset: args.offset,
|
||||
data: btoa(content.slice(Number(args.offset), Number(args.offset) + Number(args.len))),
|
||||
});
|
||||
}
|
||||
if (command === "doc_open") return Promise.reject(new Error("No live document in this fixture"));
|
||||
if (command === "deploy_sync" && syncState.enabled) {
|
||||
const steps = args.steps as { path: string; action: string }[];
|
||||
syncState.transfers.push(steps.map((step) => ({ ...step })));
|
||||
writes.push(command);
|
||||
for (const step of steps) {
|
||||
if (step.action === "upload") remote[step.path] = local[step.path]!;
|
||||
else if (step.action === "download") local[step.path] = remote[step.path]!;
|
||||
else if (step.action === "deleteLocal") delete local[step.path];
|
||||
else if (step.action === "deleteRemote") delete remote[step.path];
|
||||
}
|
||||
return Promise.resolve({ files: steps.length, skipped: 0, errors: [] });
|
||||
}
|
||||
if (command === "deploy_compare") {
|
||||
const name = path.slice(root.length + 1);
|
||||
if (local[name] === undefined && remote[name] === undefined) return Promise.resolve([]);
|
||||
const identical = local[name] === remote[name];
|
||||
return Promise.resolve([
|
||||
{
|
||||
path: name,
|
||||
remotePath: `/srv/app/${name}`,
|
||||
local: local[name] === undefined ? null : { size: local[name]!.length, mtime: 1_700_000_100 },
|
||||
remote:
|
||||
remote[name] === undefined
|
||||
? null
|
||||
: { size: remote[name]!.length, mtime: identical ? 1_700_000_100 : 1_700_000_000 },
|
||||
status:
|
||||
local[name] === undefined
|
||||
? "remoteOnly"
|
||||
: remote[name] === undefined
|
||||
? "localOnly"
|
||||
: identical
|
||||
? "same"
|
||||
: "localNewer",
|
||||
},
|
||||
]);
|
||||
}
|
||||
if (/deploy_(upload|download|sync|delete)|doc_save|fs_write/.test(command)) {
|
||||
writes.push(command);
|
||||
return Promise.reject(new Error("Unexpected write"));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const entries = Object.keys(local).map((path) => ({
|
||||
path,
|
||||
remotePath: `/srv/app/${path}`,
|
||||
local: { size: local[path]!.length, mtime: 1_700_000_100 },
|
||||
remote: { size: remote[path]!.length, mtime: 1_700_000_000 },
|
||||
status: path === "hidden.php" || path === "same.js" ? "same" : "localNewer",
|
||||
}));
|
||||
useProject.setState({
|
||||
settings: {
|
||||
...useProject.getState().settings,
|
||||
appearance: { ...useProject.getState().settings?.appearance, theme: "dark", syncWithOs: false },
|
||||
deployment: {
|
||||
servers: [
|
||||
{ ...DEFAULT_DEPLOYMENT_SERVER, id: "staging", kind: "local", name: "Staging", rootPath: "/srv/app" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
useDeployment.setState({
|
||||
sync: {
|
||||
server: "staging",
|
||||
serverName: "Staging",
|
||||
paths: [useProject.getState().project.root],
|
||||
entries,
|
||||
skipped: [],
|
||||
request: null,
|
||||
error: null,
|
||||
choices: Object.fromEntries(entries.map((entry) => [entry.path, entry.status === "same" ? "skip" : "upload"])),
|
||||
hideSame: true,
|
||||
running: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test("deployment shows mirrored lists and a selectable side-by-side content diff", async ({
|
||||
workbench: page,
|
||||
}, info) => {
|
||||
await showComparison(page);
|
||||
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
|
||||
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..", {}).locator("..").click();
|
||||
const preview = dialog.locator('[aria-label="Deployment content comparison"]');
|
||||
await expect(preview.locator(".cm-editor")).toHaveCount(2);
|
||||
await expect(preview.locator(".cm-editor").first()).toContainText("getPerfilesValidos");
|
||||
await expect(preview.locator(".cm-editor").last()).not.toContainText("getPerfilesValidos");
|
||||
await expect(preview.locator(".cm-editor").last().locator(".cm-content")).toHaveAttribute("contenteditable", "false");
|
||||
await expect(dialog.getByRole("toolbar", { name: "Diff" })).toBeVisible();
|
||||
await expect(dialog.getByRole("separator", { name: "Resize file list and diff preview" })).toBeVisible();
|
||||
await dialog.screenshot({ path: info.outputPath("deployment-comparison.png"), animations: "disabled" });
|
||||
await page.getByLabel("Action perfil.php", { exact: true }).selectOption("skip");
|
||||
await grid.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Synchronize Selected and Enter transfer one file at a time while the comparison stays open", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await showComparison(page);
|
||||
await page.evaluate(() => {
|
||||
(window as unknown as { deploymentSyncState: { enabled: boolean } }).deploymentSyncState.enabled = true;
|
||||
});
|
||||
const dialog = page.getByRole("dialog", { name: "Diff Between 'local files' and 'files on Staging'" });
|
||||
const grid = dialog.getByRole("grid", { name: "Files to synchronize" });
|
||||
await grid.getByRole("combobox", { name: "Action perfil.php" }).locator("..").locator("..").click();
|
||||
await dialog.getByRole("button", { name: "Synchronize Selected", exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(grid.getByRole("combobox", { name: "Action perfil.php" })).toHaveCount(0);
|
||||
await expect(grid.getByRole("combobox", { name: "Action app.js" })).toHaveValue("upload");
|
||||
await expect(dialog.locator('[aria-label="Deployment content comparison"] .cm-editor').last()).toContainText(
|
||||
"deployed",
|
||||
);
|
||||
await grid.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(dialog.getByRole("button", { name: "Synchronize All", exact: true })).toBeDisabled();
|
||||
await expect(dialog.getByRole("button", { name: "Synchronize Selected", exact: true })).toBeDisabled();
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(
|
||||
await page.evaluate(
|
||||
() => (window as unknown as { deploymentSyncState: { transfers: unknown[] } }).deploymentSyncState.transfers,
|
||||
),
|
||||
).toEqual([[{ path: "perfil.php", action: "upload" }], [{ path: "app.js", action: "upload" }]]);
|
||||
});
|
||||
|
||||
test("binary content finds changes hidden by metadata and file filters keep the preview in sync", async ({
|
||||
workbench: page,
|
||||
}) => {
|
||||
await showComparison(page);
|
||||
await page.getByLabel("Compare by", { exact: true }).selectOption("content");
|
||||
await expect(page.getByLabel("Action hidden.php", { exact: true })).toHaveValue("upload");
|
||||
await expect(page.getByLabel("Action same.js", { exact: true })).toHaveCount(0);
|
||||
await page.getByLabel("Filter by name", { exact: true }).fill("hidden.php");
|
||||
const preview = page.locator('[aria-label="Deployment content comparison"]');
|
||||
await expect(preview.locator(".cm-editor").first()).toContainText("alpha");
|
||||
await expect(preview.locator(".cm-editor").last()).toContainText("bravo");
|
||||
await page.getByLabel("Action hidden.php", { exact: true }).selectOption("skip");
|
||||
expect(await page.evaluate(() => (window as unknown as { deploymentWrites: string[] }).deploymentWrites)).toEqual([]);
|
||||
});
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Pull Members Up / Push Members Down. The language server is stubbed at the IPC layer: the
|
||||
* Java side is covered by plugins/java-usages/tests/hierarchy.py against a real JDT LS.
|
||||
*/
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, openFromTree, test } from "./fixtures";
|
||||
|
||||
const captureDir = process.env.OPENIDEA_ACCEPTANCE_DIR;
|
||||
|
||||
/** Opens the dialog as the action does after the server answered, for the editor's view. */
|
||||
async function openDialog(page: Page, kind: "pullUp" | "pushDown") {
|
||||
await page.evaluate(async (kind) => {
|
||||
const module = "/src/editor/refactor/hierarchy.ts";
|
||||
const { useHierarchyDialog } = await import(/* @vite-ignore */ module);
|
||||
const view = (document.querySelector(".cm-content") as unknown as { cmTile: { view: { state: { doc: unknown } } } })
|
||||
.cmTile.view;
|
||||
const pullUp = kind === "pullUp";
|
||||
useHierarchyDialog.setState({
|
||||
request: {
|
||||
seq: 1,
|
||||
view,
|
||||
kind,
|
||||
doc: 1,
|
||||
pos: { line: 0, col: 0 },
|
||||
text: view.state.doc,
|
||||
info: {
|
||||
className: pullUp ? "Dog" : "Animal",
|
||||
targets: pullUp
|
||||
? [
|
||||
{ id: "animal", name: "Animal", qualifiedName: "zoo.Animal", interface: false },
|
||||
{ id: "speaker", name: "Speaker", qualifiedName: "zoo.Speaker", interface: true },
|
||||
]
|
||||
: [
|
||||
{ id: "dog", name: "Dog", qualifiedName: "zoo.Dog", interface: false },
|
||||
{ id: "cat", name: "Cat", qualifiedName: "zoo.Cat", interface: false },
|
||||
],
|
||||
members: pullUp
|
||||
? [
|
||||
{ id: "tail", label: "tail : int", kind: "field", selected: false, abstractable: false },
|
||||
{ id: "bark", label: "bark() : String", kind: "method", selected: true, abstractable: true },
|
||||
{ id: "wag", label: "wag() : int", kind: "method", selected: false, abstractable: true },
|
||||
]
|
||||
: [
|
||||
{ id: "name", label: "name : String", kind: "field", selected: false, abstractable: false },
|
||||
{ id: "describe", label: "describe() : String", kind: "method", selected: true, abstractable: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
}, kind);
|
||||
}
|
||||
|
||||
/** Stubs the server's check and the edit application; returns what reached them. */
|
||||
async function stubServer(page: Page, problems: { severity: string; message: string }[]) {
|
||||
await page.evaluate(async (problems) => {
|
||||
const module = "/src/platform/ipc.ts";
|
||||
const { commands } = await import(/* @vite-ignore */ module);
|
||||
// Only the stubbed commands answer; window lookups by other features keep working.
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", {
|
||||
configurable: true,
|
||||
value: {
|
||||
transformCallback: () => 1,
|
||||
invoke: async () => null,
|
||||
metadata: { currentWindow: { label: "main" }, currentWebview: { label: "main", windowLabel: "main" } },
|
||||
},
|
||||
});
|
||||
const seen = ((window as unknown as { hierarchySeen: unknown[] }).hierarchySeen = [] as unknown[]);
|
||||
commands.lspHierarchyRefactor = async (...args: unknown[]) => {
|
||||
seen.push({ refactor: args.slice(3) });
|
||||
return {
|
||||
status: "ok",
|
||||
data: {
|
||||
problems,
|
||||
edit: {
|
||||
files: [
|
||||
{
|
||||
kind: "edit",
|
||||
path: "/dev/acme-api/README.md",
|
||||
edits: [{ range: { start: { line: 0, col: 0 }, end: { line: 0, col: 0 } }, new_text: "" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
commands.applyWorkspaceEdit = async (edit: unknown) => {
|
||||
seen.push({ apply: edit });
|
||||
return { status: "ok", data: null };
|
||||
};
|
||||
}, problems);
|
||||
}
|
||||
|
||||
const seen = (page: Page) => page.evaluate(() => (window as unknown as { hierarchySeen: unknown[] }).hierarchySeen);
|
||||
|
||||
test("the editor's Refactor menu offers Pull Members Up and Push Members Down", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
await editor.locator(".cm-content").click({ button: "right" });
|
||||
await page.getByRole("menuitem", { name: "Refactor", exact: true }).hover();
|
||||
await expect(page.getByRole("menuitem", { name: /^Pull Members Up…/ })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: /^Push Members Down…/ })).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/01-refactor-menu.png` });
|
||||
await page.keyboard.press("Escape");
|
||||
});
|
||||
|
||||
test("Pull Members Up: members, interface destination, conflicts, then the edit", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await openDialog(page, "pullUp");
|
||||
const dialog = page.getByRole("dialog", { name: "Pull Members Up" });
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByText("Pull up members of Dog to:")).toBeVisible();
|
||||
const member = (label: string) => dialog.getByRole("row").filter({ hasText: label });
|
||||
// The member at the caret starts checked; fields can't be made abstract.
|
||||
await expect(member("bark() : String").getByRole("checkbox").first()).toBeChecked();
|
||||
await expect(member("wag() : int").getByRole("checkbox").first()).not.toBeChecked();
|
||||
await member("tail : int").getByRole("checkbox").first().check();
|
||||
await expect(member("tail : int").getByRole("checkbox", { name: "Make abstract" })).toBeDisabled();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/02-pull-up-dialog.png` });
|
||||
|
||||
// To an interface, methods are declared abstract there.
|
||||
await dialog.getByRole("button", { name: "Destination class" }).click();
|
||||
await page.getByRole("option", { name: /zoo\.Speaker/ }).click();
|
||||
const abstractBark = member("bark() : String").getByRole("checkbox", { name: "Make abstract" });
|
||||
await expect(abstractBark).toBeChecked();
|
||||
await expect(abstractBark).toBeDisabled();
|
||||
await expect(dialog.getByText("Methods pulled up to an interface are declared abstract there.")).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/03-pull-up-to-interface.png` });
|
||||
|
||||
await stubServer(page, [
|
||||
{ severity: "error", message: "Field 'zoo.Dog.tail' is not accessible from type 'zoo.Speaker'" },
|
||||
]);
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
const problems = page.getByRole("alertdialog").or(page.getByRole("dialog", { name: "Problems Detected" }));
|
||||
await expect(problems.getByText(/is not accessible from type/)).toBeVisible();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/04-problems-detected.png` });
|
||||
// Cancel: nothing applied, the dialog stays.
|
||||
await problems.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toBeVisible();
|
||||
expect(await seen(page)).toEqual([
|
||||
{
|
||||
refactor: [
|
||||
"pullUp",
|
||||
"speaker",
|
||||
[
|
||||
{ id: "tail", makeAbstract: false },
|
||||
{ id: "bark", makeAbstract: true },
|
||||
],
|
||||
],
|
||||
},
|
||||
]);
|
||||
// Continue: the edit is applied and the dialog closes.
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
await problems.getByRole("button", { name: "Continue", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
await expect.poll(async () => (await seen(page)).some((s) => "apply" in (s as object))).toBe(true);
|
||||
});
|
||||
|
||||
test("Push Members Down keeps an abstract declaration when asked", async ({ workbench: page }) => {
|
||||
await openFromTree(page, "README.md");
|
||||
await openDialog(page, "pushDown");
|
||||
const dialog = page.getByRole("dialog", { name: "Push Members Down" });
|
||||
await expect(dialog.getByText("Push members of Animal down to:")).toBeVisible();
|
||||
await expect(dialog.getByText("Dog, Cat")).toBeVisible();
|
||||
const describe = dialog.getByRole("row").filter({ hasText: "describe() : String" });
|
||||
await describe.getByRole("checkbox", { name: "Keep abstract" }).check();
|
||||
if (captureDir) await page.screenshot({ path: `${captureDir}/05-push-down-dialog.png` });
|
||||
await stubServer(page, []);
|
||||
await dialog.getByRole("button", { name: "Refactor", exact: true }).click();
|
||||
await expect(dialog).toBeHidden();
|
||||
const calls = await seen(page);
|
||||
expect(calls[0]).toEqual({ refactor: ["pushDown", null, [{ id: "describe", makeAbstract: true }]] });
|
||||
expect(calls.some((s) => "apply" in (s as object))).toBe(true);
|
||||
});
|
||||
@@ -27,3 +27,55 @@ test("stripe buttons toggle tool windows", async ({ workbench: page }) => {
|
||||
await stripe.getByRole("button", { name: "Problems", exact: true }).click();
|
||||
await expect(problems).toBeHidden();
|
||||
});
|
||||
|
||||
for (const reducedMotion of ["no-preference", "reduce"] as const) {
|
||||
test(`project opening advances simulated progress with ${reducedMotion} motion`, async ({ workbench: page }) => {
|
||||
await page.emulateMedia({ reducedMotion });
|
||||
await page.evaluate(async () => {
|
||||
const path = "/src/platform/project.ts";
|
||||
const { useProject } = await import(/* @vite-ignore */ path);
|
||||
useProject.setState({ opening: true, openingPath: "/work/large-project" });
|
||||
});
|
||||
const progress = page.locator("[data-project-opening]").getByRole("progressbar");
|
||||
await expect(progress).toBeVisible();
|
||||
for (const theme of ["light", "dark"]) {
|
||||
await page.evaluate((theme) => (document.documentElement.dataset.theme = theme), theme);
|
||||
const frames = await progress.evaluate((track) => {
|
||||
const fill = track.firstElementChild!;
|
||||
const animation = fill.getAnimations()[0];
|
||||
if (!animation) throw new Error("Loading progress has no activity indicator");
|
||||
animation.pause();
|
||||
const duration = Number(animation.effect!.getComputedTiming().duration);
|
||||
return [0, 0.25, 0.5, 0.75, 1, 1.5, 2].map((phase) => {
|
||||
animation.currentTime = duration * phase;
|
||||
const outer = track.getBoundingClientRect();
|
||||
const inner = fill.getBoundingClientRect();
|
||||
const style = getComputedStyle(fill);
|
||||
return {
|
||||
trackWidth: outer.width,
|
||||
fillWidth: inner.width,
|
||||
height: inner.height,
|
||||
left: inner.left - outer.left,
|
||||
right: outer.right - inner.right,
|
||||
opacity: Number(style.opacity),
|
||||
color: style.backgroundColor,
|
||||
};
|
||||
});
|
||||
});
|
||||
for (const frame of frames) {
|
||||
expect(frame.height).toBeGreaterThan(0);
|
||||
expect(frame.fillWidth).toBeGreaterThan(0);
|
||||
expect(frame.fillWidth).toBeLessThan(frame.trackWidth);
|
||||
expect(frame.left).toBeGreaterThanOrEqual(-0.5);
|
||||
expect(frame.right).toBeGreaterThanOrEqual(-0.5);
|
||||
expect(frame.opacity).toBeGreaterThanOrEqual(0.6);
|
||||
expect(frame.color).not.toBe("rgba(0, 0, 0, 0)");
|
||||
}
|
||||
const widths = frames.map((frame) => frame.fillWidth);
|
||||
expect(widths[0]).toBeLessThan(widths[1]!);
|
||||
for (let i = 1; i < widths.length; i++) expect(widths[i]).toBeGreaterThanOrEqual(widths[i - 1]!);
|
||||
expect(widths[4]! / frames[4]!.trackWidth).toBeCloseTo(0.92, 2);
|
||||
expect(widths[6]).toBeCloseTo(widths[4]!, 2);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loaded 100 of 497 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user