mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 18:38:33 -10:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c0717358df | ||
|
|
f96ca468a8 | ||
|
|
25d930b780 | ||
|
|
1ab4edc0d7 | ||
|
|
309bb48f2c | ||
|
|
75773dd889 | ||
|
|
1cab24fea1 | ||
|
|
bbbdcb465e | ||
|
|
e367f6757e | ||
|
|
619daa1489 | ||
|
|
2945d20208 | ||
|
|
e68b35f534 | ||
|
|
adc308eb99 | ||
|
|
e0408cc7d7 | ||
|
|
56796fb2c6 | ||
|
|
c614355753 | ||
|
|
7a9c72ca24 | ||
|
|
b23b95ec00 | ||
|
|
17a121e7f5 | ||
|
|
303d5950bf | ||
|
|
4cfdb972eb | ||
|
|
d235c432ec | ||
|
|
d3ea6f6daf |
No files matched your search
Generated
+4
-53
@@ -4319,6 +4319,7 @@ name = "ide-host"
|
||||
version = "0.1.38"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"criterion",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4675,7 +4676,6 @@ dependencies = [
|
||||
"keyring-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -5425,10 +5425,7 @@ version = "0.1.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"libc",
|
||||
"plain",
|
||||
"redox_syscall 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6603,7 +6600,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
]
|
||||
@@ -6773,12 +6770,6 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.10.2"
|
||||
@@ -7236,15 +7227,6 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redox_users"
|
||||
version = "0.5.3"
|
||||
@@ -7529,20 +7511,6 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-config"
|
||||
version = "0.58.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "993346101d4741a59c82bafc6b645c52ce3e7b0820f13e4b5d48cdb591e3d955"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"globset",
|
||||
"log",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-cryptovec"
|
||||
version = "0.62.0"
|
||||
@@ -8360,7 +8328,7 @@ dependencies = [
|
||||
"objc2-foundation 0.3.2",
|
||||
"objc2-quartz-core 0.3.2",
|
||||
"raw-window-handle",
|
||||
"redox_syscall 0.5.18",
|
||||
"redox_syscall",
|
||||
"tracing",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
@@ -8624,7 +8592,7 @@ dependencies = [
|
||||
"stringprep",
|
||||
"thiserror 2.0.21",
|
||||
"tracing",
|
||||
"whoami 2.1.3",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -10566,12 +10534,6 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
@@ -11146,17 +11108,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
|
||||
@@ -299,9 +299,11 @@ fn appimage_fixes() -> Vec<(OsString, Option<OsString>)> {
|
||||
const APPIMAGE_LAUNCH_VARS: [&str; 4] = ["APPDIR", "APPIMAGE", "ARGV0", "OWD"];
|
||||
|
||||
/// What to change in `vars` (an environment) so a child sees the host's environment rather
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` is dropped (a saved
|
||||
/// `<NAME>_ORIG` wins when present), variables left empty are removed, and so are the
|
||||
/// runtime's own launch variables. Nothing changes when `APPDIR` is not set.
|
||||
/// than the AppImage's: every `:`-separated entry inside `$APPDIR` — or inside *any*
|
||||
/// AppImage mount, since an in-app update restart inherits the previous mount's entries
|
||||
/// alongside the new one — is dropped (a saved `<NAME>_ORIG` wins when present),
|
||||
/// variables left empty are removed, and so are the runtime's own launch variables.
|
||||
/// Nothing changes when `APPDIR` is not set.
|
||||
fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<OsString>)> {
|
||||
let get = |name: &str| vars.iter().find(|(k, _)| k == name).map(|(_, v)| v);
|
||||
let Some(appdir) =
|
||||
@@ -309,7 +311,12 @@ fn appimage_env_fixes(vars: &[(OsString, OsString)]) -> Vec<(OsString, Option<Os
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
let inside = |entry: &str| entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
let inside = |entry: &str| {
|
||||
let own = entry.strip_prefix(appdir).is_some_and(|rest| rest.is_empty() || rest.starts_with('/'));
|
||||
// AppImages extract to `/tmp/.mount_<name>`; an entry under another mount is
|
||||
// another image's (e.g. the pre-update one a restart inherited), never the host's.
|
||||
own || entry.starts_with("/tmp/.mount_")
|
||||
};
|
||||
let mut fixes = Vec::new();
|
||||
for (key, value) in vars {
|
||||
let (Some(name), Some(value)) = (key.to_str(), value.to_str()) else { continue };
|
||||
@@ -715,7 +722,7 @@ mod tests {
|
||||
("PATH", "/tmp/.mount_OpenIDabc/usr/bin/:/tmp/.mount_OpenIDabc/usr/sbin/:/usr/local/bin:/usr/bin"),
|
||||
("XDG_DATA_DIRS", "/tmp/.mount_OpenIDabc/usr/share:/usr/share:/usr/local/share:/usr/share"),
|
||||
("HOME", "/home/me"),
|
||||
("JAVA_HOME", "/tmp/.mount_OpenIDabcdef/jdk"),
|
||||
("JAVA_HOME", "/opt/jdk"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
@@ -728,6 +735,27 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entries_of_a_previous_updates_mount_are_dropped() {
|
||||
// An in-app update restart inherits the old mount's entries: a child's curl
|
||||
// must not load the old image's libraries (they can still be mounted).
|
||||
let got = fixes(&[
|
||||
("APPDIR", "/tmp/.mount_OpenIDjIlFFA"),
|
||||
("LD_LIBRARY_PATH", "/tmp/.mount_OpenIDjIlFFA/usr/lib/:/tmp/.mount_OpenIDdaNGfM/usr/lib/:/usr/local/lib"),
|
||||
("PATH", "/tmp/.mount_OpenIDjIlFFA/usr/bin/:/tmp/.mount_OpenIDdaNGfM/usr/bin/:/usr/bin"),
|
||||
("GIO_MODULE_DIR", "/tmp/.mount_OpenIDdaNGfM/usr/lib/gio/modules"),
|
||||
]);
|
||||
assert_eq!(
|
||||
got,
|
||||
[
|
||||
fix("APPDIR", None),
|
||||
fix("LD_LIBRARY_PATH", Some("/usr/local/lib")),
|
||||
fix("PATH", Some("/usr/bin")),
|
||||
fix("GIO_MODULE_DIR", None),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variables_left_empty_are_removed() {
|
||||
// AppRun prepends to an unset variable, leaving a trailing `:`; hooks set single paths.
|
||||
|
||||
@@ -850,6 +850,8 @@ pub struct JavaSettings {
|
||||
/// Project JDK home; empty = per module, the installed JDK matching the
|
||||
/// Java version it targets.
|
||||
pub jdk: String,
|
||||
/// Project Java language level (`8`, `11`, `17`, `21`, `25`); empty = SDK default.
|
||||
pub language_level: String,
|
||||
/// JDK home the Java language server (jdtls) runs on, apart from the project
|
||||
/// JDK (IDEA's "JDK for importer"); empty = the project JDK when new enough,
|
||||
/// else the first installed one that is.
|
||||
@@ -953,6 +955,98 @@ impl Default for TypeScriptSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Dependency scope for a module dependency or project library (IDEA's Modules › Dependencies).
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DependencyScope {
|
||||
#[default]
|
||||
Compile,
|
||||
Test,
|
||||
Runtime,
|
||||
Provided,
|
||||
}
|
||||
|
||||
/// A project-level library (Project Structure › Libraries).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectLibrary {
|
||||
pub name: String,
|
||||
/// Class archives (`.jar`) or class directories.
|
||||
pub classes: Vec<String>,
|
||||
/// Source archives (`-sources.jar`, `.zip`) or source directories.
|
||||
pub sources: Vec<String>,
|
||||
/// Documentation archives (`-javadoc.jar`) or directories.
|
||||
pub javadocs: Vec<String>,
|
||||
pub scope: DependencyScope,
|
||||
}
|
||||
|
||||
/// Dependency entry of a module (Project Structure › Modules › Dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleDependency {
|
||||
/// `"library"` (references a `ProjectLibrary` by name or path) or `"module"` (references another module).
|
||||
pub kind: String,
|
||||
pub target: String,
|
||||
pub scope: DependencyScope,
|
||||
pub exported: bool,
|
||||
}
|
||||
|
||||
/// Per-module Project Structure overrides (SDK, language level, output paths, dependencies).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ModuleStructureSettings {
|
||||
/// Module SDK home override; empty = inherit Project SDK.
|
||||
pub jdk: String,
|
||||
/// Module language level override; empty = inherit Project language level.
|
||||
pub language_level: String,
|
||||
/// Module compiler output directory override; empty = inherit Project compiler output.
|
||||
pub compiler_output: String,
|
||||
/// Module test compiler output directory override; empty = default.
|
||||
pub test_compiler_output: String,
|
||||
/// Additional module dependencies.
|
||||
pub dependencies: Vec<ModuleDependency>,
|
||||
}
|
||||
|
||||
/// Framework facet attached to a module (Project Structure › Facets).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct FacetConfig {
|
||||
/// `"web"` | `"spring"` | `"jpa"`.
|
||||
pub kind: String,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Deployment descriptor path (e.g. `src/main/webapp/WEB-INF/web.xml`, `src/main/resources/META-INF/persistence.xml`).
|
||||
pub descriptor: String,
|
||||
/// Web resource root directory for Web facets (e.g. `src/main/webapp`).
|
||||
pub web_root: String,
|
||||
}
|
||||
|
||||
/// Packaging type of a Project Structure artifact.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ArtifactKind {
|
||||
#[default]
|
||||
Jar,
|
||||
War,
|
||||
WarExploded,
|
||||
}
|
||||
|
||||
/// Packaging artifact definition (Project Structure › Artifacts).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ArtifactConfig {
|
||||
pub name: String,
|
||||
pub kind: ArtifactKind,
|
||||
/// Module relative path (`"."` for root module).
|
||||
pub module: String,
|
||||
/// Output directory (relative to project root or absolute).
|
||||
pub output_directory: String,
|
||||
/// Main class for executable JAR artifacts.
|
||||
pub main_class: String,
|
||||
/// Project library names included in the packaged artifact (`WEB-INF/lib` or JAR manifest).
|
||||
pub include_libraries: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct ProjectStructureSettings {
|
||||
@@ -969,9 +1063,28 @@ pub struct ProjectStructureSettings {
|
||||
pub content_roots: Vec<String>,
|
||||
/// "Exclude files": `;`-separated name patterns (`*.log;tmp*`) excluded at any depth.
|
||||
pub exclude_files: String,
|
||||
/// Project compiler output directory (relative to project root or absolute; empty = default).
|
||||
pub compiler_output: String,
|
||||
/// Per-module SDK, language level, output paths and dependency overrides, keyed by module relative path (`"."` for root).
|
||||
pub modules: BTreeMap<String, ModuleStructureSettings>,
|
||||
/// Project-level libraries (`JAR`s / class folders / source archives).
|
||||
pub libraries: Vec<ProjectLibrary>,
|
||||
/// Module framework facets (`web`, `spring`, `jpa`).
|
||||
pub facets: Vec<FacetConfig>,
|
||||
/// Packaging artifacts (`jar`, `war`, `warExploded`).
|
||||
pub artifacts: Vec<ArtifactConfig>,
|
||||
}
|
||||
|
||||
impl ProjectStructureSettings {
|
||||
/// Overrides belong to the module's full path, never just its directory name.
|
||||
pub fn module_settings(
|
||||
&self,
|
||||
root: &std::path::Path,
|
||||
module: &std::path::Path,
|
||||
) -> Option<&ModuleStructureSettings> {
|
||||
self.modules.iter().find_map(|(key, settings)| (root.join(key) == module).then_some(settings))
|
||||
}
|
||||
|
||||
/// The "Exclude files" name patterns.
|
||||
pub fn exclude_file_patterns(&self) -> Vec<String> {
|
||||
self.exclude_files.split(';').map(str::trim).filter(|p| !p.is_empty()).map(str::to_owned).collect()
|
||||
@@ -1152,6 +1265,64 @@ mod tests {
|
||||
assert_eq!(s.file_excludes(), vec!["*.log", "tmp*"]);
|
||||
assert!(Settings::default().files.file_types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_structure_modules_libraries_facets_and_artifacts_round_trip() {
|
||||
let old: Settings = serde_json::from_str(r#"{"projectStructure":{"sourceRoots":["src"]}}"#).unwrap();
|
||||
assert!(old.project_structure.compiler_output.is_empty());
|
||||
assert!(old.project_structure.modules.is_empty());
|
||||
assert!(old.project_structure.libraries.is_empty());
|
||||
assert!(old.project_structure.facets.is_empty());
|
||||
assert!(old.project_structure.artifacts.is_empty());
|
||||
assert!(old.languages.java.language_level.is_empty());
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.language_level = "21".into();
|
||||
settings.project_structure.compiler_output = "out/production".into();
|
||||
settings.project_structure.modules.insert(
|
||||
"backend".into(),
|
||||
ModuleStructureSettings {
|
||||
jdk: "/opt/jdk-21".into(),
|
||||
language_level: "21".into(),
|
||||
compiler_output: "backend/target/classes".into(),
|
||||
test_compiler_output: "backend/target/test-classes".into(),
|
||||
dependencies: vec![ModuleDependency {
|
||||
kind: "library".into(),
|
||||
target: "custom-lib".into(),
|
||||
scope: DependencyScope::Compile,
|
||||
exported: true,
|
||||
}],
|
||||
},
|
||||
);
|
||||
settings.project_structure.libraries.push(ProjectLibrary {
|
||||
name: "custom-lib".into(),
|
||||
classes: vec!["lib/custom.jar".into()],
|
||||
sources: vec!["lib/custom-sources.jar".into()],
|
||||
javadocs: Vec::new(),
|
||||
scope: DependencyScope::Compile,
|
||||
});
|
||||
settings.project_structure.facets.push(FacetConfig {
|
||||
kind: "web".into(),
|
||||
module: "backend".into(),
|
||||
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
|
||||
web_root: "src/main/webapp".into(),
|
||||
});
|
||||
settings.project_structure.artifacts.push(ArtifactConfig {
|
||||
name: "backend:war exploded".into(),
|
||||
kind: ArtifactKind::WarExploded,
|
||||
module: "backend".into(),
|
||||
output_directory: "out/artifacts/backend_war_exploded".into(),
|
||||
main_class: String::new(),
|
||||
include_libraries: vec!["custom-lib".into()],
|
||||
});
|
||||
|
||||
let value = serde_json::to_value(&settings).unwrap();
|
||||
assert_eq!(value["languages"]["java"]["languageLevel"], "21");
|
||||
assert_eq!(value["projectStructure"]["compilerOutput"], "out/production");
|
||||
assert_eq!(value["projectStructure"]["artifacts"][0]["kind"], "warExploded");
|
||||
let restored: Settings = serde_json::from_value(value).unwrap();
|
||||
assert_eq!(restored, settings);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -113,6 +113,8 @@ pub struct CommitSettings {
|
||||
pub body_limit: u32,
|
||||
pub show_right_margin: bool,
|
||||
pub wrap_at_margin: bool,
|
||||
/// Plugin before-commit checks turned off (`<plugin id>/<check id>`); the rest run.
|
||||
pub disabled_plugin_checks: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for CommitSettings {
|
||||
@@ -132,6 +134,7 @@ impl Default for CommitSettings {
|
||||
body_limit: 72,
|
||||
show_right_margin: true,
|
||||
wrap_at_margin: false,
|
||||
disabled_plugin_checks: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,6 @@ pub use manifest::{
|
||||
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};
|
||||
pub use registry::{ContributionInfo, ExtensionRegistry, LOCALES_DIR, LOCALES_POINT, PreparedSet};
|
||||
pub use ui::{UiContribution, UiDecl, UiPoint, UiStore, to_json};
|
||||
pub use when::validate_when;
|
||||
@@ -9,6 +9,11 @@ use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The point behind a package's `locales/<tag>.json` catalogs: it has no
|
||||
/// `[[contributes.locales]]` table, the directory declares it.
|
||||
pub const LOCALES_POINT: &str = "locales";
|
||||
pub const LOCALES_DIR: &str = "locales";
|
||||
|
||||
/// A contribution as the plugin details page lists it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -127,6 +132,20 @@ impl ExtensionRegistry {
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
}
|
||||
if let Some(point) = self.point(LOCALES_POINT)
|
||||
&& !pkg.manifest.contributes.contains_key(LOCALES_POINT)
|
||||
&& pkg.dir.join(LOCALES_DIR).is_dir()
|
||||
{
|
||||
let prepared = point
|
||||
.prepare(pkg, toml::Value::Table(Default::default()))
|
||||
.map_err(|e| format!("{LOCALES_DIR}: {}", strip_prefix(&e, LOCALES_POINT)))?;
|
||||
for f in &prepared.files {
|
||||
if !set.files.contains(f) {
|
||||
set.files.push(f.clone());
|
||||
}
|
||||
}
|
||||
set.items.push((point.id(), prepared));
|
||||
}
|
||||
Ok(set)
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ ide-vfs.workspace = true
|
||||
regex-syntax.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
blake3.workspace = true
|
||||
ide-index.workspace = true
|
||||
ide-syntax.workspace = true
|
||||
ide-search.workspace = true
|
||||
|
||||
@@ -91,9 +91,10 @@ async fn resolve_launch(
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
apply_library_classpath(&mut info, &project.root, &settings.project_structure.libraries, test);
|
||||
let compile_ms = started.elapsed().as_millis() - lsp_ms;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
// The module/project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::module_jdk(&project.root, &module, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
// The build model's level includes what the module inherits from its parent POMs.
|
||||
@@ -153,6 +154,41 @@ fn entry_key(path: &str, windows: bool) -> String {
|
||||
if windows { key.to_lowercase() } else { key }
|
||||
}
|
||||
|
||||
fn apply_library_classpath(
|
||||
info: &mut JavaLaunchInfo,
|
||||
root: &std::path::Path,
|
||||
libraries: &[ide_core::settings::ProjectLibrary],
|
||||
test: bool,
|
||||
) {
|
||||
use ide_core::settings::DependencyScope;
|
||||
let entries: Vec<_> = libraries
|
||||
.iter()
|
||||
.flat_map(|lib| {
|
||||
let allowed = match lib.scope {
|
||||
DependencyScope::Compile | DependencyScope::Runtime => true,
|
||||
DependencyScope::Test | DependencyScope::Provided => test,
|
||||
};
|
||||
lib.classes.iter().filter_map(move |entry| {
|
||||
ide_sdk::apply::setting_path(root, entry).map(|path| (path.to_string_lossy().into_owned(), allowed))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
// JDT may already have returned these archives on either classpath.
|
||||
for paths in [&mut info.class_paths, &mut info.module_paths] {
|
||||
paths.retain(|path| {
|
||||
let matching: Vec<_> = entries.iter().filter(|(entry, _)| same_entry(path, entry, cfg!(windows))).collect();
|
||||
matching.is_empty() || matching.iter().any(|(_, allowed)| *allowed)
|
||||
});
|
||||
}
|
||||
for (entry, allowed) in entries {
|
||||
if allowed
|
||||
&& !info.class_paths.iter().chain(&info.module_paths).any(|path| same_entry(path, &entry, cfg!(windows)))
|
||||
{
|
||||
info.class_paths.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn same_entry(a: &str, b: &str, windows: bool) -> bool {
|
||||
a == b || entry_key(a, windows) == entry_key(b, windows)
|
||||
}
|
||||
@@ -220,6 +256,36 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_scopes_filter_jdt_classpaths_and_module_paths() {
|
||||
use ide_core::settings::{DependencyScope, ProjectLibrary};
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let scopes =
|
||||
[DependencyScope::Compile, DependencyScope::Runtime, DependencyScope::Test, DependencyScope::Provided];
|
||||
let libraries: Vec<_> = scopes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, scope)| ProjectLibrary {
|
||||
classes: vec![format!("lib/{i}.jar")],
|
||||
scope: *scope,
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
let paths: Vec<_> =
|
||||
(0..4).map(|i| root.path().join(format!("lib/{i}.jar")).to_string_lossy().into_owned()).collect();
|
||||
for test in [false, true] {
|
||||
let mut info =
|
||||
JavaLaunchInfo { class_paths: paths.clone(), module_paths: paths.clone(), ..Default::default() };
|
||||
apply_library_classpath(&mut info, root.path(), &libraries, test);
|
||||
let expected = if test { &paths[..] } else { &paths[..2] };
|
||||
assert_eq!(info.class_paths, expected);
|
||||
assert_eq!(info.module_paths, expected);
|
||||
}
|
||||
let mut info = JavaLaunchInfo::default();
|
||||
apply_library_classpath(&mut info, root.path(), &libraries, false);
|
||||
assert_eq!(info.class_paths, &paths[..2]);
|
||||
}
|
||||
|
||||
/// JDT LS and our build model spell a module output differently on Windows; the
|
||||
/// snapshot must still replace it, or resources (MyBatis mapper XML) load twice.
|
||||
#[test]
|
||||
|
||||
+202
-22
@@ -7,6 +7,7 @@ use ide_core::error::ErrorKind;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
use ide_plugin::ProviderDocument;
|
||||
use std::path::Path;
|
||||
|
||||
async fn prepare_actions(
|
||||
project: std::sync::Arc<crate::Project>,
|
||||
@@ -85,7 +86,12 @@ const PLUGIN_SERVER: ServerId = ServerId(0);
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct PluginActionRef {
|
||||
plugin: String,
|
||||
data: String,
|
||||
data: Option<String>,
|
||||
/// Opened once the action is applied.
|
||||
open: Option<ide_core::IpcPath>,
|
||||
/// Listed by the plugin's `file-actions` export, which resolves it.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
file: bool,
|
||||
}
|
||||
|
||||
/// A plugin's `completion-item.kind` (wit/providers.wit lists them).
|
||||
@@ -443,7 +449,7 @@ pub async fn lsp_code_actions(
|
||||
};
|
||||
let plugin_actions: Vec<CodeAction> = plugin_actions
|
||||
.into_iter()
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, a))
|
||||
.map(|(plugin, a)| plugin_code_action(&plugin, &project.root, a))
|
||||
.filter(|a| {
|
||||
only.is_empty()
|
||||
|| a.kind.as_ref().is_some_and(|k| only.iter().any(|o| k == o || k.starts_with(&format!("{o}."))))
|
||||
@@ -459,23 +465,26 @@ pub async fn lsp_code_actions(
|
||||
prepare_actions(project, doc, version, actions).await
|
||||
}
|
||||
|
||||
fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and no edits, the plugin computes them when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.data.is_some();
|
||||
let files: Vec<_> = a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|(path, edits)| ide_core::FileEdit::Edit { path: path.into(), edits, version: None })
|
||||
.collect();
|
||||
let data = a
|
||||
.data
|
||||
.filter(|_| lazy)
|
||||
.and_then(|data| serde_json::to_value(PluginActionRef { plugin: plugin.to_owned(), data }).ok());
|
||||
fn plugin_code_action(plugin: &str, root: &Path, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
// With `data` and nothing to apply, the plugin computes the edits when the action is picked.
|
||||
let lazy = a.edits.is_empty() && a.operations.is_empty() && a.data.is_some();
|
||||
let (files, open, disabled_reason) = match plugin_file_edits(root, &a) {
|
||||
Ok((files, open)) => (files, open, None),
|
||||
Err(reason) => (Vec::new(), None, Some(reason)),
|
||||
};
|
||||
let data = (lazy || open.is_some())
|
||||
.then(|| PluginActionRef {
|
||||
plugin: plugin.to_owned(),
|
||||
data: a.data.filter(|_| lazy),
|
||||
open: open.map(Into::into),
|
||||
file: a.file_action,
|
||||
})
|
||||
.and_then(|r| serde_json::to_value(r).ok());
|
||||
CodeAction {
|
||||
title: a.title,
|
||||
kind: a.kind,
|
||||
is_preferred: a.preferred,
|
||||
disabled_reason: None,
|
||||
disabled_reason,
|
||||
edit: (!lazy).then(|| WorkspaceEdit { files, preconditions: Vec::new() }),
|
||||
command: None,
|
||||
data,
|
||||
@@ -483,6 +492,53 @@ fn plugin_code_action(plugin: &str, a: ide_plugin::CodeAction) -> CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
/// The action's file operations, then its text edits, as one workspace edit's files, and the
|
||||
/// file to open. An action with file operations or an `open` target stays inside the project
|
||||
/// root as a whole: a path outside it disables the action.
|
||||
fn plugin_file_edits(
|
||||
root: &Path,
|
||||
a: &ide_plugin::CodeAction,
|
||||
) -> Result<(Vec<ide_core::FileEdit>, Option<std::path::PathBuf>), String> {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let confine = !a.operations.is_empty() || a.open.is_some();
|
||||
let confined = |path: &Path| ide_plugin::resolve_new_in_root(root, &path.to_string_lossy());
|
||||
let mut files = Vec::new();
|
||||
let mut created_with_text = Vec::new();
|
||||
for operation in &a.operations {
|
||||
match operation {
|
||||
FileOperation::Create { path, text, overwrite } => {
|
||||
let path = confined(path)?;
|
||||
files.push(FileEdit::Create { path: path.clone().into(), overwrite: *overwrite });
|
||||
if !text.is_empty() {
|
||||
let start = Range::point(Pos::new(0, 0));
|
||||
files.push(FileEdit::Edit {
|
||||
path: path.clone().into(),
|
||||
edits: vec![TextEdit { range: start, new_text: text.clone() }],
|
||||
version: None,
|
||||
});
|
||||
created_with_text.push(path);
|
||||
}
|
||||
}
|
||||
FileOperation::Rename { from, to } => {
|
||||
files.push(FileEdit::Rename { from: confined(from)?.into(), to: confined(to)?.into() });
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
files.push(FileEdit::Delete { path: confined(path)?.into(), recursive: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
for (path, edits) in &a.edits {
|
||||
let path = if confine { confined(path)? } else { path.clone() };
|
||||
if created_with_text.contains(&path) {
|
||||
return Err("put the text of a created file in its `create`, not in an edit".into());
|
||||
}
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: edits.clone(), version: None });
|
||||
}
|
||||
let open = a.open.as_deref().map(confined).transpose()?;
|
||||
Ok((files, open))
|
||||
}
|
||||
|
||||
/// A code action resolved by whoever offered it: its language server, or
|
||||
/// the plugin its `data` names.
|
||||
async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
|
||||
@@ -499,11 +555,15 @@ async fn resolve_action(host: &Host, window: &str, doc: DocId, action: CodeActio
|
||||
kind: action.kind.clone(),
|
||||
preferred: action.is_preferred,
|
||||
edits: Vec::new(),
|
||||
data: Some(r.data),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: r.data,
|
||||
file_action: r.file,
|
||||
};
|
||||
let plugin = r.plugin.clone();
|
||||
let resolved = blocking(move || plugins.resolve_code_action(&plugin, &d, pending)).await?;
|
||||
Ok(plugin_code_action(&r.plugin, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
let root = host.project(window)?.root.clone();
|
||||
Ok(plugin_code_action(&r.plugin, &root, ide_plugin::CodeAction { data: None, ..resolved }))
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -554,11 +614,15 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(reason) = &action.disabled_reason {
|
||||
return Err(ide_core::IdeError::invalid(reason.clone()));
|
||||
}
|
||||
if let Some(edit) = &action.edit {
|
||||
let documents = project.documents.clone();
|
||||
let edit = edit.clone();
|
||||
blocking(move || apply_edit(&documents, &edit)).await?;
|
||||
}
|
||||
open_plugin_action_target(&action, window);
|
||||
if let Some(command) = action.command
|
||||
&& !(action.edit.is_some() && is_client_follow_up(&project, doc, None, &command).await)
|
||||
{
|
||||
@@ -567,6 +631,24 @@ pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The plugin and the file its action asks to show once applied.
|
||||
fn plugin_action_open(action: &CodeAction) -> Option<(String, ide_core::IpcPath)> {
|
||||
if action.server != PLUGIN_SERVER {
|
||||
return None;
|
||||
}
|
||||
let r = action.data.clone().and_then(|d| serde_json::from_value::<PluginActionRef>(d).ok())?;
|
||||
Some((r.plugin, r.open?))
|
||||
}
|
||||
|
||||
/// Opens the file a plugin's action asked to show, now that the action is applied.
|
||||
fn open_plugin_action_target(action: &CodeAction, window: &str) {
|
||||
let (Some((plugin, path)), Some(plugins)) = (plugin_action_open(action), crate::plugins::editor_plugin_host())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
plugins.open_file(&plugin, window, path);
|
||||
}
|
||||
|
||||
/// A command after a code action's edit that no server provides: what other editors'
|
||||
/// extensions do once the edit is applied (vtsls' `_typescript.didOrganizeImports`). The edit
|
||||
/// is the action; there is nothing to run.
|
||||
@@ -677,7 +759,22 @@ pub async fn lsp_format(
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::InlayHints) else {
|
||||
return lsp.inlay_hints(doc, range).await;
|
||||
};
|
||||
let (result, hints) =
|
||||
tokio::join!(lsp.inlay_hints(doc, range), plugin_call(move || plugins.provide_inlay_hints(&d, range)));
|
||||
let hints = hints.unwrap_or_default();
|
||||
if hints.is_empty() {
|
||||
return result;
|
||||
}
|
||||
let server = match result {
|
||||
Ok(server) => server,
|
||||
Err(e) if matches!(e.kind, ErrorKind::NotFound | ErrorKind::Unavailable) => Vec::new(),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(crate::plugins::merge_inlay_hints(server, hints, range))
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
@@ -831,6 +928,8 @@ pub async fn lsp_execute_code_action(
|
||||
return Err(ide_core::IdeError::cancelled());
|
||||
}
|
||||
let edited = action.edit.is_some();
|
||||
// A plugin's action may name a file to show once applied; the caller opens it.
|
||||
let open = plugin_action_open(&action).map(|(_, path)| path);
|
||||
if let Some(edit) = action.edit {
|
||||
let documents = project.documents.clone();
|
||||
tokio::task::spawn_blocking(move || apply_edit(&documents, &edit))
|
||||
@@ -846,7 +945,7 @@ pub async fn lsp_execute_code_action(
|
||||
.execute_action_command(doc, action.server, command, answers, cancel)
|
||||
.await
|
||||
.map(|paths| paths.into_iter().map(ide_core::IpcPath::from).collect()),
|
||||
None => Ok(Vec::new()),
|
||||
None => Ok(open.into_iter().collect()),
|
||||
}
|
||||
}
|
||||
.await;
|
||||
@@ -1010,15 +1109,19 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits,
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: data.map(str::to_owned),
|
||||
file_action: false,
|
||||
};
|
||||
let lazy = plugin_code_action("acme.x", action(Vec::new(), Some("ref")));
|
||||
let root = Path::new("/p");
|
||||
let lazy = plugin_code_action("acme.x", root, action(Vec::new(), Some("ref")));
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_str()), ("acme.x", "ref"));
|
||||
assert_eq!((r.plugin.as_str(), r.data.as_deref()), ("acme.x", Some("ref")));
|
||||
// With edits, `data` is the plugin's business: applied as is.
|
||||
let edit = TextEdit { range: Range::default(), new_text: "x".into() };
|
||||
let eager = plugin_code_action("acme.x", action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
let eager = plugin_code_action("acme.x", root, action(vec![("/a".into(), vec![edit])], Some("ref")));
|
||||
assert!(eager.edit.is_some() && eager.data.is_none());
|
||||
}
|
||||
|
||||
@@ -1095,6 +1198,83 @@ mod tests {
|
||||
assert!(without_generated_helpers(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plugin_action_maps_its_file_operations_before_its_edits_and_stays_in_the_project() {
|
||||
use ide_core::FileEdit;
|
||||
use ide_plugin::FileOperation;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let root = root.canonicalize().unwrap();
|
||||
let action = |operations: Vec<FileOperation>| ide_plugin::CodeAction {
|
||||
title: "Create view".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
edits: vec![(root.join("a.txt"), vec![TextEdit { range: Range::default(), new_text: "x".into() }])],
|
||||
operations,
|
||||
open: Some(root.join("views/home.html")),
|
||||
data: None,
|
||||
file_action: true,
|
||||
};
|
||||
|
||||
let created = plugin_code_action(
|
||||
"acme.x",
|
||||
&root,
|
||||
action(vec![
|
||||
FileOperation::Create { path: "views/home.html".into(), text: "<h1/>".into(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: root.join("new.txt") },
|
||||
FileOperation::Delete { path: root.join("gone.txt") },
|
||||
]),
|
||||
);
|
||||
assert_eq!(created.disabled_reason, None);
|
||||
let files = created.edit.unwrap().files;
|
||||
let home = root.join("views/home.html");
|
||||
assert!(matches!(&files[0], FileEdit::Create { path, overwrite: false } if **path == home));
|
||||
assert!(
|
||||
matches!(&files[1], FileEdit::Edit { path, edits, .. } if **path == home && edits[0].new_text == "<h1/>")
|
||||
);
|
||||
assert!(matches!(&files[2], FileEdit::Rename { to, .. } if **to == root.join("new.txt")));
|
||||
assert!(matches!(&files[3], FileEdit::Delete { recursive: false, .. }));
|
||||
assert!(matches!(&files[4], FileEdit::Edit { path, .. } if **path == root.join("a.txt")));
|
||||
let r: PluginActionRef = serde_json::from_value(created.data.unwrap()).unwrap();
|
||||
assert_eq!(r.data, None);
|
||||
assert!(r.file, "resolved by the file-actions export");
|
||||
let opened = r.open.unwrap().to_path_buf();
|
||||
assert!(opened.components().eq(dunce::simplified(&home).components()), "{opened:?}");
|
||||
|
||||
let outside = tmp.path().join("evil.txt");
|
||||
let mut open_outside = action(Vec::new());
|
||||
open_outside.open = Some(outside.clone());
|
||||
assert!(plugin_code_action("acme.x", &root, open_outside).disabled_reason.is_some());
|
||||
let mut edit_outside = action(vec![FileOperation::Delete { path: root.join("gone.txt") }]);
|
||||
edit_outside.edits.push((outside, Vec::new()));
|
||||
assert!(plugin_code_action("acme.x", &root, edit_outside).disabled_reason.is_some());
|
||||
let mut edit_created =
|
||||
action(vec![FileOperation::Create { path: root.join("n.txt"), text: "x".into(), overwrite: false }]);
|
||||
edit_created.edits = vec![(root.join("n.txt"), Vec::new())];
|
||||
assert!(plugin_code_action("acme.x", &root, edit_created).disabled_reason.is_some());
|
||||
|
||||
for outside in [tmp.path().join("evil.txt"), "../evil.txt".into()] {
|
||||
for operation in [
|
||||
FileOperation::Create { path: outside.clone(), text: String::new(), overwrite: false },
|
||||
FileOperation::Rename { from: root.join("old.txt"), to: outside.clone() },
|
||||
FileOperation::Delete { path: outside.clone() },
|
||||
] {
|
||||
let rejected = plugin_code_action("acme.x", &root, action(vec![operation]));
|
||||
assert!(rejected.disabled_reason.is_some_and(|r| r.contains("outside the project root")));
|
||||
assert!(rejected.edit.unwrap().files.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
// `data` and nothing to apply: the file-actions export computes it when picked.
|
||||
let mut lazy = action(Vec::new());
|
||||
(lazy.edits, lazy.open, lazy.data) = (Vec::new(), None, Some("ref".into()));
|
||||
let lazy = plugin_code_action("acme.x", &root, lazy);
|
||||
assert!(lazy.edit.is_none());
|
||||
let r: PluginActionRef = serde_json::from_value(lazy.data.unwrap()).unwrap();
|
||||
assert_eq!((r.data.as_deref(), r.file), (Some("ref"), true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_completion_kind_the_plugin_api_documents_is_understood() {
|
||||
let wit = include_str!("../../../../wit/providers.wit");
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! [`HostApi`] for one plugin's code: what its imports reach in the IDE.
|
||||
//! Capabilities were checked by the runtime before any of this runs.
|
||||
|
||||
use super::{PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use super::{CommitAnswer, CommitRequest, CommitSelection, PluginContextKey, PluginEvent, PluginEventSink};
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_core::{CancellationToken, TextEdit};
|
||||
use ide_extension::Package;
|
||||
use ide_language::ProgramPin;
|
||||
use ide_plugin::{
|
||||
EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem,
|
||||
CommitChange, CommitDraft, EventKind, HostApi, HostError, HttpRequest, HttpResponse, NotificationLevel,
|
||||
ProcessOutput, Prompt, RepositoryStatus, StatusItem,
|
||||
};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
@@ -18,11 +18,16 @@ use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Largest storage of one plugin (keys + values).
|
||||
const MAX_STORAGE: usize = 1 << 20;
|
||||
const MAX_STORAGE: usize = 64 << 20;
|
||||
const MAX_KEY: usize = 256;
|
||||
/// Most status bar items per plugin.
|
||||
const MAX_STATUS_ITEMS: usize = 8;
|
||||
const MAX_CONTEXT_KEYS: usize = 100;
|
||||
/// Most diff text of a pending commit, all files together.
|
||||
const MAX_COMMIT_DIFF: usize = 1 << 20;
|
||||
/// How long a window may take to describe its pending commit (it saves editors first).
|
||||
const COMMIT_SELECTION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const COMMIT_MESSAGE_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// (window or `None` for every window, plugin, key).
|
||||
type ContextKey = (Option<String>, String, String);
|
||||
@@ -51,6 +56,10 @@ pub trait PluginWorkspace: Send + Sync {
|
||||
/// Shows `items` as producer `source`'s diagnostics of `path` (replacing earlier ones).
|
||||
fn publish_diagnostics(&self, source: &str, path: &Path, items: Vec<ide_core::Diagnostic>)
|
||||
-> Result<(), HostError>;
|
||||
/// The encoding git content of `path` is read in (the project's file encoding).
|
||||
fn vcs_encoding(&self, _path: &Path) -> ide_vcs::TextEncoding {
|
||||
encoding_rs::UTF_8.into()
|
||||
}
|
||||
/// The user's values of `plugin`'s settings: the project's at `root`
|
||||
/// (over global), or global.
|
||||
fn plugin_settings(&self, _plugin: &str, _root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
@@ -150,10 +159,15 @@ struct TaskEntry {
|
||||
cancelled: bool,
|
||||
}
|
||||
|
||||
/// A question a plugin's call waits on.
|
||||
/// A question a plugin's call waits on: the user's (a prompt) or a window's.
|
||||
struct PromptEntry {
|
||||
plugin: String,
|
||||
answer: std::sync::mpsc::SyncSender<Option<Vec<String>>>,
|
||||
answer: Waiter,
|
||||
}
|
||||
|
||||
enum Waiter {
|
||||
Prompt(std::sync::mpsc::SyncSender<Option<Vec<String>>>),
|
||||
Commit(std::sync::mpsc::SyncSender<CommitAnswer>),
|
||||
}
|
||||
|
||||
/// State shared by the host and every plugin's [`PluginApi`].
|
||||
@@ -182,6 +196,8 @@ pub(super) struct Shared {
|
||||
pub pins: Arc<RwLock<HashMap<(String, String), ProgramPin>>>,
|
||||
/// Validated UI declarations (settings schemas, declared views).
|
||||
pub ui: Arc<ide_extension::UiStore>,
|
||||
/// The UI language the windows report (BCP 47).
|
||||
pub locale: RwLock<String>,
|
||||
}
|
||||
|
||||
impl Shared {
|
||||
@@ -193,6 +209,7 @@ impl Shared {
|
||||
) -> Arc<Self> {
|
||||
Arc::new(Self {
|
||||
ui,
|
||||
locale: RwLock::new("en".into()),
|
||||
sink,
|
||||
workspace: RwLock::new(None),
|
||||
secrets: RwLock::new(Arc::new(FileSecretStore { dir: data_dir.clone() })),
|
||||
@@ -211,7 +228,7 @@ impl Shared {
|
||||
})
|
||||
}
|
||||
|
||||
fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
pub(super) fn workspace(&self) -> Option<Arc<dyn PluginWorkspace>> {
|
||||
self.workspace.read().clone()
|
||||
}
|
||||
|
||||
@@ -358,8 +375,22 @@ impl Shared {
|
||||
|
||||
/// The user answered (`Some`) or dismissed (`None`) a prompt.
|
||||
pub fn answer_prompt(&self, id: u64, answer: Option<Vec<String>>) -> bool {
|
||||
let Some(prompt) = self.prompts.lock().remove(&id) else { return false };
|
||||
prompt.answer.send(answer).is_ok()
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Prompt(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Prompt(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
/// A window answered a [`CommitRequest`].
|
||||
pub fn answer_commit(&self, id: u64, answer: CommitAnswer) -> bool {
|
||||
let mut prompts = self.prompts.lock();
|
||||
if !matches!(prompts.get(&id), Some(PromptEntry { answer: Waiter::Commit(_), .. })) {
|
||||
return false;
|
||||
}
|
||||
let Some(PromptEntry { answer: Waiter::Commit(tx), .. }) = prompts.remove(&id) else { return false };
|
||||
tx.send(answer).is_ok()
|
||||
}
|
||||
|
||||
fn end_task(&self, id: u64) {
|
||||
@@ -368,11 +399,62 @@ impl Shared {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn storage_file(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage.json")
|
||||
/// The plugin's stored values: one file per key, so a write only
|
||||
/// rewrites its own value.
|
||||
pub fn storage_dir(&self, plugin: &str) -> PathBuf {
|
||||
self.data_dir.join(plugin).join("storage")
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_io(e: std::io::Error) -> HostError {
|
||||
HostError::Failed(format!("storage: {e}"))
|
||||
}
|
||||
|
||||
/// A key's file name: its hash, since keys may hold any character.
|
||||
fn entry_name(key: &str) -> String {
|
||||
blake3::hash(key.as_bytes()).to_hex().to_string()
|
||||
}
|
||||
|
||||
/// A stored entry: the key's length (2 bytes, little-endian), the key, then the value.
|
||||
fn decode_entry(bytes: &[u8]) -> Option<(&str, &str)> {
|
||||
let len = usize::from(u16::from_le_bytes(bytes.get(..2)?.try_into().ok()?));
|
||||
let key = std::str::from_utf8(bytes.get(2..2 + len)?).ok()?;
|
||||
let value = std::str::from_utf8(&bytes[2 + len..]).ok()?;
|
||||
Some((key, value))
|
||||
}
|
||||
|
||||
fn write_entry(dir: &Path, key: &str, value: &str) -> Result<(), HostError> {
|
||||
std::fs::create_dir_all(dir).map_err(storage_io)?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let len = u16::try_from(key.len()).expect("keys are at most MAX_KEY bytes");
|
||||
let mut bytes = Vec::with_capacity(2 + key.len() + value.len());
|
||||
bytes.extend_from_slice(&len.to_le_bytes());
|
||||
bytes.extend_from_slice(key.as_bytes());
|
||||
bytes.extend_from_slice(value.as_bytes());
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(storage_io)
|
||||
}
|
||||
|
||||
/// The stored entries' files and the bytes of their keys and values.
|
||||
fn entries(dir: &Path) -> Result<impl Iterator<Item = (PathBuf, usize)>, HostError> {
|
||||
let list = match std::fs::read_dir(dir) {
|
||||
Ok(list) => list,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new().into_iter()),
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for entry in list {
|
||||
let entry = entry.map_err(storage_io)?;
|
||||
let path = entry.path();
|
||||
if path.extension().is_some() {
|
||||
continue; // a write that didn't finish
|
||||
}
|
||||
let size = entry.metadata().map_err(storage_io)?.len();
|
||||
out.push((path, usize::try_from(size).unwrap_or(usize::MAX).saturating_sub(2)));
|
||||
}
|
||||
Ok(out.into_iter())
|
||||
}
|
||||
|
||||
pub(super) struct PluginApi {
|
||||
pub pkg: Package,
|
||||
pub shared: Arc<Shared>,
|
||||
@@ -383,21 +465,24 @@ impl PluginApi {
|
||||
self.pkg.id()
|
||||
}
|
||||
|
||||
fn load_storage(&self) -> Result<BTreeMap<String, String>, HostError> {
|
||||
match std::fs::read(self.shared.storage_file(self.id())) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}"))),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
|
||||
Err(e) => Err(HostError::Failed(format!("storage: {e}"))),
|
||||
/// The storage directory, after moving a `storage.json` written by an
|
||||
/// older version into it.
|
||||
fn storage(&self) -> Result<PathBuf, HostError> {
|
||||
let dir = self.shared.storage_dir(self.id());
|
||||
let legacy = self.shared.data_dir.join(self.id()).join("storage.json");
|
||||
match std::fs::read(&legacy) {
|
||||
Ok(bytes) => {
|
||||
let map: BTreeMap<String, String> =
|
||||
serde_json::from_slice(&bytes).map_err(|e| HostError::Failed(format!("storage: {e}")))?;
|
||||
for (key, value) in &map {
|
||||
write_entry(&dir, key, value)?;
|
||||
}
|
||||
std::fs::remove_file(&legacy).map_err(storage_io)?;
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(e) => return Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn save_storage(&self, map: &BTreeMap<String, String>) -> Result<(), HostError> {
|
||||
let file = self.shared.storage_file(self.id());
|
||||
let bytes = serde_json::to_vec(map).map_err(|e| HostError::Failed(e.to_string()))?;
|
||||
let io = |e: std::io::Error| HostError::Failed(format!("storage: {e}"));
|
||||
std::fs::create_dir_all(file.parent().expect("has parent")).map_err(io)?;
|
||||
let tmp = file.with_extension("tmp");
|
||||
std::fs::write(&tmp, bytes).and_then(|()| std::fs::rename(&tmp, &file)).map_err(io)
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// The program approved for the (already checked) `process:` grant,
|
||||
@@ -540,6 +625,91 @@ fn kill_group(child: &mut std::process::Child) {
|
||||
ide_core::process::kill_tree(child);
|
||||
}
|
||||
|
||||
impl PluginApi {
|
||||
/// Sends `request` to `client`'s window and waits for its answer, until
|
||||
/// `cancel` fires, the plugin stops or `timeout` passes.
|
||||
fn ask_commit(
|
||||
&self,
|
||||
client: &str,
|
||||
request: CommitRequest,
|
||||
cancel: &CancellationToken,
|
||||
timeout: Duration,
|
||||
) -> Result<CommitAnswer, HostError> {
|
||||
let id = self.shared.next_prompt.fetch_add(1, Ordering::Relaxed);
|
||||
let (answer, answers) = std::sync::mpsc::sync_channel(1);
|
||||
{
|
||||
let blocked = self.shared.prompts_blocked.lock();
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Commit(answer) });
|
||||
}
|
||||
let request_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
Some(client),
|
||||
PluginEvent::CommitRequested { plugin_id: self.id().to_owned(), request_id: request_id.clone(), request },
|
||||
);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match answers.recv_timeout(Duration::from_millis(100)) {
|
||||
Ok(answer) => return Ok(answer),
|
||||
Err(std::sync::mpsc::RecvTimeoutError::Timeout)
|
||||
if !cancel.is_cancelled() && Instant::now() < deadline => {}
|
||||
Err(e) => {
|
||||
self.shared.prompts.lock().remove(&id);
|
||||
self.shared.sink.emit(Some(client), PluginEvent::PromptClosed { prompt_id: request_id });
|
||||
return Err(match e {
|
||||
std::sync::mpsc::RecvTimeoutError::Timeout if !cancel.is_cancelled() => {
|
||||
HostError::Failed("the window did not answer".into())
|
||||
}
|
||||
_ => HostError::Cancelled,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The changes `selection` commits, diffed repository by repository.
|
||||
pub(super) fn commit_draft(
|
||||
workspace: &dyn PluginWorkspace,
|
||||
selection: CommitSelection,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<CommitDraft, HostError> {
|
||||
let mut changes = Vec::new();
|
||||
let mut budget = MAX_COMMIT_DIFF;
|
||||
for repo in &selection.repositories {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
let Some(r) = ide_vcs::Repo::discover(&repo.root.0).map_err(failed)? else {
|
||||
return Err(HostError::NotFound(format!("{} is not in a git repository", repo.root.0.display())));
|
||||
};
|
||||
let pending = r.pending_changes(&repo.options, budget, &|path| Ok(workspace.vcs_encoding(path))).map_err(
|
||||
|e| match e.kind {
|
||||
ide_core::error::ErrorKind::InvalidInput => HostError::Invalid(e.message),
|
||||
_ => HostError::Failed(e.message),
|
||||
},
|
||||
)?;
|
||||
let root = to_ipc(r.root());
|
||||
for c in pending {
|
||||
budget -= c.diff.as_ref().map_or(0, String::len).min(budget);
|
||||
changes.push(CommitChange {
|
||||
repository: root.clone(),
|
||||
path: to_ipc(&c.path),
|
||||
old_path: c.old_path.as_deref().map(to_ipc),
|
||||
status: c.status.to_owned(),
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(CommitDraft { revision: selection.revision, message: selection.message, amend: selection.amend, changes })
|
||||
}
|
||||
|
||||
fn failed(e: impl std::fmt::Display) -> HostError {
|
||||
HostError::Failed(e.to_string())
|
||||
}
|
||||
@@ -577,7 +747,10 @@ impl HostApi for PluginApi {
|
||||
if blocked.contains(self.id()) {
|
||||
return Err(HostError::Cancelled);
|
||||
}
|
||||
self.shared.prompts.lock().insert(id, PromptEntry { plugin: self.id().to_owned(), answer });
|
||||
self.shared
|
||||
.prompts
|
||||
.lock()
|
||||
.insert(id, PromptEntry { plugin: self.id().to_owned(), answer: Waiter::Prompt(answer) });
|
||||
}
|
||||
let prompt_id = id.to_string();
|
||||
self.shared.sink.emit(
|
||||
@@ -597,6 +770,37 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let client = from_window(client, "read the pending commit")?;
|
||||
let workspace = self.shared.workspace().ok_or_else(|| HostError::Failed("no workspace".into()))?;
|
||||
let root =
|
||||
workspace.project_of(client).ok_or_else(|| HostError::Invalid("the window has no project".into()))?;
|
||||
if !workspace.trusted(&root) {
|
||||
return Err(HostError::Invalid("the project is in Restricted Mode: git does not run there".into()));
|
||||
}
|
||||
let answer = self.ask_commit(client, CommitRequest::Selection, cancel, COMMIT_SELECTION_TIMEOUT)?;
|
||||
match answer {
|
||||
CommitAnswer::NoRepository => Ok(None),
|
||||
CommitAnswer::Selection { selection } => commit_draft(&*workspace, selection, cancel).map(Some),
|
||||
CommitAnswer::Done | CommitAnswer::Stale => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let client = from_window(client, "set the commit message")?;
|
||||
let request = CommitRequest::SetMessage { revision, message };
|
||||
match self.ask_commit(client, request, &CancellationToken::new(), COMMIT_MESSAGE_TIMEOUT)? {
|
||||
CommitAnswer::Done => Ok(()),
|
||||
CommitAnswer::Stale => Err(HostError::Stale),
|
||||
CommitAnswer::NoRepository => Err(HostError::Invalid("the window's project has no repository".into())),
|
||||
CommitAnswer::Selection { .. } => Err(HostError::Failed("unexpected answer".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn reveal_view(&self, client: Option<&str>, view: &str) -> Result<(), HostError> {
|
||||
let client = from_window(client, "reveal a view")?;
|
||||
let declared = self.shared.ui.items(super::ui_points::VIEWS, Some(self.id())).iter().any(|i| i.key == view);
|
||||
@@ -692,30 +896,47 @@ impl HostApi for PluginApi {
|
||||
}
|
||||
|
||||
fn storage_get(&self, key: &str) -> Result<Option<String>, HostError> {
|
||||
Ok(self.load_storage()?.get(key).cloned())
|
||||
let file = self.storage()?.join(entry_name(key));
|
||||
match std::fs::read(&file) {
|
||||
Ok(bytes) => Ok(decode_entry(&bytes).filter(|(k, _)| *k == key).map(|(_, v)| v.to_owned())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(storage_io(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn storage_set(&self, key: &str, value: Option<String>) -> Result<(), HostError> {
|
||||
if key.is_empty() || key.len() > MAX_KEY {
|
||||
return Err(HostError::Invalid(format!("keys are 1–{MAX_KEY} bytes")));
|
||||
}
|
||||
let mut map = self.load_storage()?;
|
||||
let changed = match value {
|
||||
Some(v) => map.insert(key.to_owned(), v.clone()).as_ref() != Some(&v),
|
||||
None => map.remove(key).is_some(),
|
||||
let dir = self.storage()?;
|
||||
let file = dir.join(entry_name(key));
|
||||
let Some(value) = value else {
|
||||
return match std::fs::remove_file(&file) {
|
||||
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(storage_io(e)),
|
||||
_ => Ok(()),
|
||||
};
|
||||
};
|
||||
if !changed {
|
||||
return Ok(());
|
||||
let others: usize = entries(&dir)?.filter(|(name, _)| *name != file).map(|(_, size)| size).sum();
|
||||
if others + key.len() + value.len() > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!(
|
||||
"storage is limited to {} MiB (UTF-8 keys and values)",
|
||||
MAX_STORAGE >> 20
|
||||
)));
|
||||
}
|
||||
let size: usize = map.iter().map(|(k, v)| k.len() + v.len()).sum();
|
||||
if size > MAX_STORAGE {
|
||||
return Err(HostError::Invalid(format!("storage is limited to {} KiB", MAX_STORAGE >> 10)));
|
||||
}
|
||||
self.save_storage(&map)
|
||||
write_entry(&dir, key, &value)
|
||||
}
|
||||
|
||||
fn storage_keys(&self, prefix: &str) -> Result<Vec<String>, HostError> {
|
||||
Ok(self.load_storage()?.into_keys().filter(|k| k.starts_with(prefix)).collect())
|
||||
let dir = self.storage()?;
|
||||
let mut keys = Vec::new();
|
||||
for (file, _) in entries(&dir)? {
|
||||
let bytes = std::fs::read(&file).map_err(storage_io)?;
|
||||
if let Some((key, _)) = decode_entry(&bytes).filter(|(k, _)| k.starts_with(prefix)) {
|
||||
keys.push(key.to_owned());
|
||||
}
|
||||
}
|
||||
keys.sort();
|
||||
Ok(keys)
|
||||
}
|
||||
|
||||
fn vcs_status(&self, root: &Path) -> Result<Option<RepositoryStatus>, HostError> {
|
||||
@@ -1020,6 +1241,10 @@ impl HostApi for PluginApi {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String> {
|
||||
let schema: Vec<super::ui_points::SettingSchema> = self
|
||||
.shared
|
||||
@@ -1112,6 +1337,15 @@ impl PluginWorkspace for HostWorkspace {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn vcs_encoding(&self, path: &Path) -> ide_vcs::TextEncoding {
|
||||
HostWorkspace::projects(self)
|
||||
.into_iter()
|
||||
.filter(|p| path.starts_with(&p.root))
|
||||
.max_by_key(|p| p.root.as_os_str().len())
|
||||
.and_then(|p| p.vcs_encoding(path, None).ok())
|
||||
.unwrap_or_else(|| encoding_rs::UTF_8.into())
|
||||
}
|
||||
|
||||
fn plugin_settings(&self, plugin: &str, root: Option<&Path>) -> serde_json::Map<String, serde_json::Value> {
|
||||
let Some(host) = self.0.upgrade() else { return serde_json::Map::new() };
|
||||
let settings = match root.and_then(|r| host.projects().into_iter().find(|p| p.root == r)) {
|
||||
@@ -1136,6 +1370,57 @@ mod tests {
|
||||
PluginApi { pkg: Package::new(manifest, dir.to_path_buf()), shared }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_moves_an_older_storage_json_to_one_file_per_key() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let legacy = tmp.path().join(".data/acme.lint/storage.json");
|
||||
std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
|
||||
std::fs::write(&legacy, r#"{"a":"1","b/c":"two"}"#).unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a", "b/c"]);
|
||||
assert_eq!(api.storage_get("b/c").unwrap().as_deref(), Some("two"));
|
||||
assert!(!legacy.exists());
|
||||
api.storage_set("a", Some("3".into())).unwrap();
|
||||
api.storage_set("b/c", None).unwrap();
|
||||
assert_eq!(api.storage_keys("").unwrap(), ["a"]);
|
||||
assert_eq!(api.storage_get("a").unwrap().as_deref(), Some("3"));
|
||||
assert_eq!(api.storage_get("b/c").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_host_recreation() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let value = "é".repeat(2 << 20);
|
||||
{
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
api.storage_set("index", Some(value.clone())).unwrap();
|
||||
}
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
assert_eq!(api.storage_get("index").unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set("index", None).unwrap();
|
||||
assert_eq!(api.storage_get("index").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_quota_counts_utf8_keys_and_values_and_preserves_data_on_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let api = api(tmp.path(), Arc::default());
|
||||
let key = "index";
|
||||
let mut value = "é".repeat(((64 << 20) - key.len()) / 2);
|
||||
value.push('x');
|
||||
api.storage_set(key, Some(value.clone())).unwrap();
|
||||
|
||||
for (key, value) in [("x", String::new()), (key, format!("{value}x"))] {
|
||||
let err = api.storage_set(key, Some(value)).unwrap_err();
|
||||
assert!(matches!(err, HostError::Invalid(ref message) if message.contains("64 MiB")), "{err}");
|
||||
}
|
||||
assert_eq!(api.storage_keys("").unwrap(), [key]);
|
||||
assert_eq!(api.storage_get(key).unwrap().as_deref(), Some(value.as_str()));
|
||||
api.storage_set(key, Some("small".into())).unwrap();
|
||||
api.storage_set("other", Some("value".into())).unwrap();
|
||||
assert_eq!(api.storage_get("other").unwrap().as_deref(), Some("value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repository_and_search_paths_match_ipc_events() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
//! The Commit view's side of the `commit` API, as the window commands
|
||||
//! `plugin_answer_commit` / `plugin_commit_checks` serve it — the desktop for
|
||||
//! local projects, `openidea-server` for remote ones.
|
||||
|
||||
use super::{CommitSelection, PluginCommitCheckRef, PluginCommitCheckResult, PluginHost};
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` on what `selection` commits,
|
||||
/// until request `request` is cancelled.
|
||||
pub async fn commit_checks(
|
||||
host: &crate::Host,
|
||||
plugins: Arc<PluginHost>,
|
||||
window: &str,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
host.require_trust(window)?;
|
||||
let invocation = super::views::view_invocation(host, window, None, None)?;
|
||||
let cancel = host.begin_project_read_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || plugins.commit_checks(invocation, selection, checks, &cancel))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
.and_then(|r| r);
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
use ide_core::Range;
|
||||
use ide_lsp::types::{InlayHint, InlayKind};
|
||||
use ide_plugin::PluginInlayHint;
|
||||
|
||||
pub(crate) fn merge(server: Vec<InlayHint>, plugin: Vec<PluginInlayHint>, range: Range) -> Vec<InlayHint> {
|
||||
let inside = |h: &PluginInlayHint| {
|
||||
let at = (h.position.line, h.position.col);
|
||||
(range.start.line, range.start.col) <= at && at <= (range.end.line, range.end.col)
|
||||
};
|
||||
let mut out = server;
|
||||
out.extend(plugin.into_iter().filter(inside).map(|h| InlayHint {
|
||||
position: h.position,
|
||||
label: h.label,
|
||||
kind: match h.kind.as_str() {
|
||||
"type" => InlayKind::Type,
|
||||
"parameter" => InlayKind::Parameter,
|
||||
_ => InlayKind::Other,
|
||||
},
|
||||
padding_left: h.padding_left,
|
||||
padding_right: h.padding_right,
|
||||
tooltip: h.tooltip,
|
||||
parts: Vec::new(),
|
||||
}));
|
||||
out.sort_by_key(|h| (h.position.line, h.position.col));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::Pos;
|
||||
|
||||
fn plugin(line: u32, col: u32, label: &str, kind: &str) -> PluginInlayHint {
|
||||
PluginInlayHint {
|
||||
position: Pos::new(line, col),
|
||||
label: label.into(),
|
||||
kind: kind.into(),
|
||||
tooltip: Some("from the plugin".into()),
|
||||
padding_left: true,
|
||||
padding_right: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn server(line: u32, col: u32, label: &str) -> InlayHint {
|
||||
InlayHint {
|
||||
position: Pos::new(line, col),
|
||||
label: label.into(),
|
||||
kind: InlayKind::Parameter,
|
||||
padding_left: false,
|
||||
padding_right: true,
|
||||
tooltip: None,
|
||||
parts: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summary(hints: &[InlayHint]) -> Vec<(u32, u32, String)> {
|
||||
hints.iter().map(|h| (h.position.line, h.position.col, h.label.clone())).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_hints_join_the_servers_in_position_order() {
|
||||
let range = Range::new(Pos::new(0, 0), Pos::new(10, 0));
|
||||
let merged = merge(
|
||||
vec![server(2, 4, "name:"), server(5, 1, "id:")],
|
||||
vec![plugin(3, 9, ": string", "type"), plugin(2, 1, "GET /orders", "other")],
|
||||
range,
|
||||
);
|
||||
assert_eq!(
|
||||
summary(&merged),
|
||||
[
|
||||
(2, 1, "GET /orders".to_owned()),
|
||||
(2, 4, "name:".to_owned()),
|
||||
(3, 9, ": string".to_owned()),
|
||||
(5, 1, "id:".to_owned())
|
||||
]
|
||||
);
|
||||
let typed = merged.iter().find(|h| h.label == ": string").unwrap();
|
||||
assert!(matches!(typed.kind, InlayKind::Type));
|
||||
assert!(typed.padding_left && !typed.padding_right);
|
||||
assert_eq!(typed.tooltip.as_deref(), Some("from the plugin"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_kinds_are_other_and_hints_outside_the_range_are_dropped() {
|
||||
let range = Range::new(Pos::new(2, 5), Pos::new(4, 3));
|
||||
let merged = merge(
|
||||
Vec::new(),
|
||||
vec![plugin(2, 4, "before", "type"), plugin(3, 0, "in", "route"), plugin(4, 4, "after", "type")],
|
||||
range,
|
||||
);
|
||||
assert_eq!(summary(&merged), [(3, 0, "in".to_owned())]);
|
||||
assert!(matches!(merged[0].kind, InlayKind::Other));
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,14 @@
|
||||
//! projects) and folders linked with `openidea-plugin link` (`links.json`).
|
||||
|
||||
mod api;
|
||||
pub mod commit;
|
||||
mod hover;
|
||||
mod icon_extensions;
|
||||
mod icon_patterns;
|
||||
mod icon_settings;
|
||||
mod icons;
|
||||
pub mod index_ops;
|
||||
mod inlay_hints;
|
||||
mod install;
|
||||
pub mod intellij;
|
||||
mod market;
|
||||
@@ -43,6 +45,7 @@ pub(crate) use hover::merge as merge_hover;
|
||||
pub use icons::{
|
||||
MAX_PLUGIN_ICON, map_asset as icon_theme_map_asset, plugin_icon, sanitize_svg, svg_prefix as icon_theme_svg_prefix,
|
||||
};
|
||||
pub(crate) use inlay_hints::merge as merge_inlay_hints;
|
||||
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,
|
||||
@@ -697,6 +700,15 @@ impl PluginHost {
|
||||
Ok(self.ui.assets_with_prefix(id, prefix).into_iter().map(|(k, v)| (k, v.to_string())).collect())
|
||||
}
|
||||
|
||||
/// The UI language windows run in, as `locale()` reports it to plugin code.
|
||||
pub fn set_ui_locale(&self, tag: &str) {
|
||||
*self.shared.locale.write() = tag.to_owned();
|
||||
}
|
||||
|
||||
pub fn ui_locale(&self) -> String {
|
||||
self.shared.locale.read().clone()
|
||||
}
|
||||
|
||||
pub fn set_enabled(&self, id: &str, enabled: bool) -> IdeResult<()> {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let plugin = self.get(id)?;
|
||||
@@ -1366,6 +1378,67 @@ impl PluginHost {
|
||||
prompt_id.parse().is_ok_and(|id| self.shared.answer_prompt(id, answer))
|
||||
}
|
||||
|
||||
/// A window answered a plugin's `CommitRequested`; false when the call no
|
||||
/// longer waits.
|
||||
pub fn answer_commit(&self, request_id: &str, answer: CommitAnswer) -> bool {
|
||||
request_id.parse().is_ok_and(|id| self.shared.answer_commit(id, answer))
|
||||
}
|
||||
|
||||
/// Runs the plugins' before-commit checks `checks` (`(plugin, check)`) on
|
||||
/// what `selection` commits. A check that fails or isn't available is
|
||||
/// reported with `error`, so the commit still stops for it.
|
||||
pub fn commit_checks(
|
||||
&self,
|
||||
invocation: Invocation,
|
||||
selection: CommitSelection,
|
||||
checks: Vec<PluginCommitCheckRef>,
|
||||
cancel: &ide_core::CancellationToken,
|
||||
) -> IdeResult<Vec<PluginCommitCheckResult>> {
|
||||
let declared = self.ui.items(ui_points::COMMIT_CHECKS, None);
|
||||
let wanted: Vec<_> = checks
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let item = declared.iter().find(|i| i.plugin_id == c.plugin_id && i.key == c.check)?;
|
||||
Some((c, item.decl["title"].as_str().unwrap_or_default().to_owned()))
|
||||
})
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let workspace = self.shared.workspace().ok_or_else(|| IdeError::unavailable("no workspace"))?;
|
||||
let draft = api::commit_draft(&*workspace, selection, cancel).map_err(|e| match e {
|
||||
HostError::Cancelled => IdeError::cancelled(),
|
||||
HostError::Invalid(m) => IdeError::invalid(m),
|
||||
e => IdeError::unavailable(e.to_string()),
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for (c, title) in wanted {
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
let granted = self
|
||||
.plugins
|
||||
.read()
|
||||
.get(&c.plugin_id)
|
||||
.is_some_and(|p| p.meta.lock().granted.contains(&Capability::VcsRead));
|
||||
let result = if granted {
|
||||
let (check, draft) = (c.check.clone(), draft.clone());
|
||||
self.code_call(&c.plugin_id, invocation.clone(), "checking the commit", move |rt, inv| {
|
||||
rt.commit_check(&check, draft.clone(), inv)
|
||||
})
|
||||
.map_err(|e| e.message)
|
||||
} else {
|
||||
Err(HostError::Denied(Capability::VcsRead.to_string()).to_string())
|
||||
};
|
||||
let (problems, error) = match result {
|
||||
Ok(problems) => (problems, None),
|
||||
Err(e) => (Vec::new(), Some(e)),
|
||||
};
|
||||
out.push(PluginCommitCheckResult { plugin_id: c.plugin_id, check: c.check, title, problems, error });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Kinds of workbench events some running or activatable plugin wants;
|
||||
/// the UI reports only these.
|
||||
pub fn event_kinds(&self) -> BTreeSet<EventKind> {
|
||||
|
||||
@@ -68,5 +68,7 @@ pub fn register_builtin_points(
|
||||
registry.register(Arc::new(UiPoint::new(p::CUSTOM_EDITORS, ui.clone(), p::custom_editor)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_ICONS, ui.clone(), p::file_icon)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::FILE_TEMPLATES, ui.clone(), p::file_template)))?;
|
||||
registry.register(Arc::new(UiPoint::new(p::COMMIT_CHECKS, ui.clone(), p::commit_check)))?;
|
||||
registry.register(Arc::new(UiPoint::new(ide_extension::LOCALES_POINT, ui.clone(), p::locales)))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -39,6 +39,7 @@ pub enum ProviderKind {
|
||||
LineMarkers,
|
||||
SemanticTokens,
|
||||
Hover,
|
||||
InlayHints,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -120,6 +121,9 @@ impl ExtensionPoint for ProvidersPoint {
|
||||
if d.kinds.contains(&ProviderKind::Hover) {
|
||||
pkg.manifest.needs_api("contributes.providers: hover", 3)?;
|
||||
}
|
||||
if d.kinds.contains(&ProviderKind::InlayHints) {
|
||||
pkg.manifest.needs_api("contributes.providers: inlay-hints", 3)?;
|
||||
}
|
||||
for l in &d.languages {
|
||||
if l != "*"
|
||||
&& (l.is_empty()
|
||||
@@ -165,11 +169,11 @@ pub(super) fn secret(_pkg: &Package, decl: toml::Value) -> Result<UiDecl, String
|
||||
|
||||
// ---- dispatch ---------------------------------------------------------------
|
||||
|
||||
use super::{LoadedPlugin, PluginHost, PluginStatus};
|
||||
use super::{LoadedPlugin, PluginEvent, PluginHost, PluginStatus};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_plugin::{
|
||||
ActorError, CallError, CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, Pending, PluginHover,
|
||||
ProviderDocument, Runtime, SemanticToken,
|
||||
PluginInlayHint, ProviderDocument, Runtime, SemanticToken,
|
||||
};
|
||||
use std::sync::Weak;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -319,6 +323,12 @@ impl PluginHost {
|
||||
})
|
||||
}
|
||||
|
||||
/// Shows `path` in window `client` after a code action of `plugin_id` was applied.
|
||||
pub fn open_file(&self, plugin_id: &str, client: &str, path: ide_core::IpcPath) {
|
||||
let event = PluginEvent::OpenLocation { plugin_id: plugin_id.to_owned(), path, line: 0, column: 0 };
|
||||
self.emit(Some(client), event);
|
||||
}
|
||||
|
||||
pub fn provide_definition(&self, doc: &ProviderDocument, at: ide_core::Pos) -> Vec<Location> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::Definition, &doc.language, move |rt, timeout| {
|
||||
@@ -357,6 +367,16 @@ impl PluginHost {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn provide_inlay_hints(&self, doc: &ProviderDocument, range: ide_core::Range) -> Vec<PluginInlayHint> {
|
||||
let d = Arc::new(doc.clone());
|
||||
self.edit_calls(ProviderKind::InlayHints, &doc.language, move |rt, timeout| {
|
||||
rt.provide_inlay_hints(&d, range, timeout)
|
||||
})
|
||||
.into_iter()
|
||||
.flat_map(|(_, hints)| hints)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Ghost text from the first plugin that has some. Not bound by the
|
||||
/// edit-path soft deadline (it may need the network) but by `cancel`:
|
||||
/// the editor cancels a request as soon as the user types on.
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
//! The commands a window needs at run time (`plugin_run_command`,
|
||||
//! `plugin_post_events`, `plugin_event_kinds`, `plugin_status_items`,
|
||||
//! `plugin_context_keys`, `plugin_cancel_task`, `plugin_answer_prompt`, `plugin_set_active_file`,
|
||||
//! `plugin_line_markers`, `plugin_view_render`, `plugin_view_event`, `plugin_service_detail`,
|
||||
//! `plugin_service_log`)
|
||||
//! `plugin_line_markers`, `plugin_set_ui_locale`, `plugin_view_render`, `plugin_view_event`,
|
||||
//! `plugin_service_detail`, `plugin_service_log`, `plugin_answer_commit`, `plugin_commit_checks`)
|
||||
//! carry the desktop commands' names and arguments, so a remote window's UI
|
||||
//! routes them here unchanged. Plugin results come back as `plugin`
|
||||
//! notifications ([`PluginEventMessage`]); secrets stay in the desktop's
|
||||
@@ -122,6 +122,7 @@ pub fn forwarded(event: &PluginEvent) -> bool {
|
||||
| PluginEvent::PromptRequested { .. }
|
||||
| PluginEvent::PromptClosed { .. }
|
||||
| PluginEvent::RevealView { .. }
|
||||
| PluginEvent::CommitRequested { .. }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -383,6 +384,14 @@ impl RemotePlugins {
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_ui_locale", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
alive(&h)?.set_ui_locale(&c.arg::<String>("tag")?);
|
||||
Ok(())
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_post_events", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
@@ -422,6 +431,26 @@ impl RemotePlugins {
|
||||
async move { Ok(alive(&h)?.answer_prompt(&c.arg::<String>("promptId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_answer_commit", move |c| {
|
||||
let h = h.clone();
|
||||
async move { Ok(alive(&h)?.answer_commit(&c.arg::<String>("requestId")?, c.arg("answer")?)) }
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_commit_checks", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
super::commit::commit_checks(
|
||||
&c.host,
|
||||
alive(&h)?,
|
||||
&c.window,
|
||||
c.arg("selection")?,
|
||||
c.arg("checks")?,
|
||||
c.arg("request")?,
|
||||
)
|
||||
.await
|
||||
}
|
||||
});
|
||||
let h = host.clone();
|
||||
d.add("plugin_set_active_file", move |c| {
|
||||
let h = h.clone();
|
||||
async move {
|
||||
|
||||
@@ -232,6 +232,55 @@ pub enum PluginEvent {
|
||||
/// Bring the tool window of the plugin's view `view` to the front.
|
||||
#[serde(rename_all = "camelCase")]
|
||||
RevealView { plugin_id: String, view: String },
|
||||
/// A command needs the window's Commit view: answer with
|
||||
/// `plugin_answer_commit(request_id, …)`. Closed like a prompt (`PromptClosed`).
|
||||
#[serde(rename_all = "camelCase")]
|
||||
CommitRequested { plugin_id: String, request_id: String, request: CommitRequest },
|
||||
}
|
||||
|
||||
/// What a plugin asks of a window's Commit view.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitRequest {
|
||||
/// What Commit would commit now ([`CommitAnswer::Selection`]); unsaved
|
||||
/// editors of the included files are saved first.
|
||||
Selection,
|
||||
/// Replace the message if the draft is still at `revision`.
|
||||
SetMessage { revision: u64, message: String },
|
||||
}
|
||||
|
||||
/// A window's answer to a [`CommitRequest`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum CommitAnswer {
|
||||
/// The window's project has no repository.
|
||||
NoRepository,
|
||||
Selection {
|
||||
selection: CommitSelection,
|
||||
},
|
||||
/// The message was replaced.
|
||||
Done,
|
||||
/// The draft is no longer at the revision.
|
||||
Stale,
|
||||
}
|
||||
|
||||
/// What a window's Commit view would commit: per repository, the options
|
||||
/// `vcs_commit` would get.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelection {
|
||||
/// Changes whenever the message, the included changes or Amend do.
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub repositories: Vec<CommitSelectionRepo>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitSelectionRepo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub options: ide_vcs::CommitOptions,
|
||||
}
|
||||
|
||||
/// Delivers events to the UI. `client` is the window that invoked the plugin
|
||||
@@ -300,3 +349,24 @@ pub struct LineMarkerInfo {
|
||||
/// Opened on click.
|
||||
pub target: Option<ide_core::Location>,
|
||||
}
|
||||
|
||||
/// A plugin's before-commit check to run (`[[contributes.commitChecks]]`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckRef {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
}
|
||||
|
||||
/// What a plugin's before-commit check found.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PluginCommitCheckResult {
|
||||
pub plugin_id: String,
|
||||
pub check: String,
|
||||
/// The check's declared title.
|
||||
pub title: String,
|
||||
pub problems: Vec<ide_plugin::CommitProblem>,
|
||||
/// The check could not run (the plugin failed, timed out or lacks `vcs.read`).
|
||||
pub error: Option<String>,
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
//!
|
||||
//! ```toml
|
||||
//! [[contributes.menus]]
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context
|
||||
//! menu = "editor.context" # projectView.context, editorTab.context, tools, viewItem.context, commit.message
|
||||
//! command = "check"
|
||||
//! when = "editorLang == java"
|
||||
//! # view = "<view id>" # required for (only allowed on) viewItem.context
|
||||
@@ -42,6 +42,10 @@
|
||||
//! icon = "icons/drawing.svg"
|
||||
//! iconDark = "icons/drawing_dark.svg"
|
||||
//!
|
||||
//! [[contributes.commitChecks]]
|
||||
//! id = "conventional"
|
||||
//! title = "Conventional Commits" # Commit Options › Before Commit; `commit-checker.check` runs it
|
||||
//!
|
||||
//! [[contributes.fileTemplates]]
|
||||
//! id = "drawing"
|
||||
//! name = "Excalidraw Drawing"
|
||||
@@ -49,14 +53,14 @@
|
||||
//! path = "templates/drawing.excalidraw"
|
||||
//! ```
|
||||
|
||||
use ide_extension::{Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use ide_extension::{LOCALES_DIR, Package, UiDecl, decode, validate_item_id, validate_when};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A view whose tree shows in the Services tool window; the plugin's
|
||||
/// `service-provider` renders the selected node's pane.
|
||||
/// `detail-provider` renders the selected node's pane.
|
||||
pub const SERVICES_LOCATION: &str = "services";
|
||||
pub const MENUS: &str = "menus";
|
||||
pub const KEYMAPS: &str = "keymaps";
|
||||
@@ -66,9 +70,13 @@ pub const VIEWS: &str = "views";
|
||||
pub const CUSTOM_EDITORS: &str = "customEditors";
|
||||
pub const FILE_ICONS: &str = "fileIcons";
|
||||
pub const FILE_TEMPLATES: &str = "fileTemplates";
|
||||
pub const COMMIT_CHECKS: &str = "commitChecks";
|
||||
|
||||
const VIEW_ITEM_MENU: &str = "viewItem.context";
|
||||
const MENU_IDS: [&str; 5] = ["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU];
|
||||
/// The toolbar above the commit message (Commit tool window and dialog).
|
||||
const COMMIT_MESSAGE_MENU: &str = "commit.message";
|
||||
const MENU_IDS: [&str; 6] =
|
||||
["editor.context", "projectView.context", "editorTab.context", "tools", VIEW_ITEM_MENU, COMMIT_MESSAGE_MENU];
|
||||
const MAX_TITLE: usize = 256;
|
||||
const MAX_SETTINGS: usize = 200;
|
||||
const MAX_PATTERNS: usize = 16;
|
||||
@@ -114,6 +122,10 @@ struct MenuDecl {
|
||||
when: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
view: Option<String>,
|
||||
/// Groups the plugin's entries of one context menu that name the same
|
||||
/// submenu under it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
submenu: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
@@ -122,9 +134,19 @@ pub(super) fn menu(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
return Err(format!("contributes.menus: unknown menu {:?} (one of {})", d.menu, MENU_IDS.join(", ")));
|
||||
}
|
||||
declared_command(pkg, MENUS, &d.command)?;
|
||||
if d.menu == COMMIT_MESSAGE_MENU {
|
||||
pkg.manifest.needs_api(&format!("contributes.menus: {COMMIT_MESSAGE_MENU}"), 3)?;
|
||||
}
|
||||
if let Some(w) = &d.when {
|
||||
validate_when(w).map_err(|e| format!("contributes.menus: {e}"))?;
|
||||
}
|
||||
if let Some(submenu) = &d.submenu {
|
||||
if !d.menu.ends_with(".context") {
|
||||
return Err(format!("contributes.menus: `submenu` is only for context menus, not {:?}", d.menu));
|
||||
}
|
||||
title(MENUS, submenu)?;
|
||||
pkg.manifest.needs_api("contributes.menus: submenu", 3)?;
|
||||
}
|
||||
let key = match (d.menu == VIEW_ITEM_MENU, &d.view) {
|
||||
(true, None) => return Err(format!("contributes.menus: {VIEW_ITEM_MENU} needs `view`")),
|
||||
(true, Some(v)) => {
|
||||
@@ -554,6 +576,80 @@ pub(super) fn file_template(pkg: &Package, decl: toml::Value) -> Result<UiDecl,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CommitCheckDecl {
|
||||
id: String,
|
||||
title: String,
|
||||
}
|
||||
|
||||
/// `[[contributes.commitChecks]]`: a before-commit check run by the plugin's
|
||||
/// `commit-checker` export, listed in Commit Options.
|
||||
pub(super) fn commit_check(pkg: &Package, decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let d: CommitCheckDecl = decode(COMMIT_CHECKS, decl)?;
|
||||
pkg.manifest.needs_api(&format!("contributes.{COMMIT_CHECKS}"), 3)?;
|
||||
validate_item_id(&d.id, "commit check id")?;
|
||||
title(COMMIT_CHECKS, &d.title)?;
|
||||
if pkg.manifest.wasm.is_none() {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs `wasm` (the checks run in the plugin's code)"));
|
||||
}
|
||||
if !pkg.manifest.capabilities.all().any(|c| *c == ide_extension::Capability::VcsRead) {
|
||||
return Err(format!("contributes.{COMMIT_CHECKS}: needs the `vcs.read` capability (checks read the commit)"));
|
||||
}
|
||||
Ok(UiDecl::new(d.id.clone(), d.title.clone(), json(&d)?))
|
||||
}
|
||||
|
||||
const MAX_CATALOG_ENTRIES: usize = 20_000;
|
||||
|
||||
pub fn locale_asset(tag: &str) -> String {
|
||||
format!("locale/{tag}")
|
||||
}
|
||||
|
||||
fn locale_tag_ok(tag: &str) -> bool {
|
||||
let mut parts = tag.split('-');
|
||||
let language = parts.next().unwrap_or_default();
|
||||
(2..=3).contains(&language.len())
|
||||
&& language.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& parts.all(|p| (1..=8).contains(&p.len()) && p.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
}
|
||||
|
||||
/// `locales/<tag>.json`: English source text → translation, for the plugin's own strings.
|
||||
pub(super) fn locales(pkg: &Package, _decl: toml::Value) -> Result<UiDecl, String> {
|
||||
let dir = Path::new(LOCALES_DIR);
|
||||
let mut names: Vec<String> = std::fs::read_dir(pkg.dir.join(dir))
|
||||
.map_err(|e| format!("{LOCALES_DIR}: {e}"))?
|
||||
.filter_map(|entry| entry.ok()?.file_name().into_string().ok())
|
||||
.filter(|name| name.ends_with(".json"))
|
||||
.collect();
|
||||
names.sort();
|
||||
let mut tags = Vec::new();
|
||||
let mut files = Vec::new();
|
||||
let mut assets = Vec::new();
|
||||
for name in names {
|
||||
let tag = name.trim_end_matches(".json");
|
||||
if !locale_tag_ok(tag) {
|
||||
return Err(format!("{LOCALES_DIR}/{name}: the file name must be a language tag such as fr or zh-CN"));
|
||||
}
|
||||
let file = dir.join(&name);
|
||||
let text = pkg.read_text(&file)?;
|
||||
let catalog: BTreeMap<String, Value> = serde_json::from_str(&text)
|
||||
.map_err(|e| format!("{}: not a JSON object of strings ({e})", file.display()))?;
|
||||
if catalog.len() > MAX_CATALOG_ENTRIES {
|
||||
return Err(format!("{}: more than {MAX_CATALOG_ENTRIES} entries", file.display()));
|
||||
}
|
||||
if let Some((key, _)) = catalog.iter().find(|(_, v)| !v.is_string()) {
|
||||
return Err(format!("{}: the value of {key:?} is not a string", file.display()));
|
||||
}
|
||||
assets.push((locale_asset(tag), text));
|
||||
tags.push(tag.to_owned());
|
||||
files.push(file);
|
||||
}
|
||||
let mut out = UiDecl::new("locales", tags.join(", "), serde_json::json!({ "tags": tags }));
|
||||
out.files = files;
|
||||
out.assets = assets;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -585,4 +681,41 @@ mod tests {
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "string", "default": 3}}}"#).is_err());
|
||||
assert!(parse_settings_schema(r#"{"properties": {"x": {"type": "object"}}}"#).is_err());
|
||||
}
|
||||
|
||||
fn package_with_catalogs(files: &[(&str, &str)]) -> (tempfile::TempDir, Package) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join(LOCALES_DIR)).unwrap();
|
||||
for (name, text) in files {
|
||||
std::fs::write(dir.path().join(LOCALES_DIR).join(name), text).unwrap();
|
||||
}
|
||||
let src = "id = \"p\"\nname = \"P\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"";
|
||||
let pkg = Package::new(ide_extension::Manifest::parse(src).unwrap(), dir.path().to_path_buf());
|
||||
(dir, pkg)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn catalogs_become_assets_by_language_tag() {
|
||||
let (_dir, pkg) = package_with_catalogs(&[
|
||||
("fr.json", r#"{"Refresh": "Actualiser"}"#),
|
||||
("zh-CN.json", r#"{"Refresh": "刷新"}"#),
|
||||
("notes.txt", "ignored"),
|
||||
]);
|
||||
let decl = locales(&pkg, toml::Value::Table(Default::default())).unwrap();
|
||||
assert_eq!(decl.json["tags"], serde_json::json!(["fr", "zh-CN"]));
|
||||
assert_eq!(decl.assets.iter().map(|(id, _)| id.as_str()).collect::<Vec<_>>(), ["locale/fr", "locale/zh-CN"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_catalogs_are_refused() {
|
||||
for (name, text) in [
|
||||
("fr.json", "{"),
|
||||
("fr.json", r#"["Refresh"]"#),
|
||||
("fr.json", r#"{"Refresh": 3}"#),
|
||||
("fr.json", r#"{"Refresh": {"a": "b"}}"#),
|
||||
("french!.json", "{}"),
|
||||
] {
|
||||
let (_dir, pkg) = package_with_catalogs(&[(name, text)]);
|
||||
assert!(locales(&pkg, toml::Value::Table(Default::default())).is_err(), "{name}: {text}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,7 @@ impl Host {
|
||||
&effective.project_structure,
|
||||
),
|
||||
);
|
||||
ide_lsp::jdtls::configure_project_structure(cfg, root, server_root, &effective.project_structure);
|
||||
let bundles: Vec<PathBuf> = ide_tools::managed_bundle(&data_dir, "java-debug")
|
||||
.into_iter()
|
||||
.chain(ide_sdk::java::usages_bundle(&data_dir))
|
||||
@@ -213,13 +214,19 @@ impl Host {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::apply::configure_java_compiler(cfg, &data_dir, &effective.inspections);
|
||||
let lang_level = ide_sdk::apply::effective_java_language_level(root, server_root, &effective);
|
||||
ide_sdk::apply::configure_java_compiler_with_level(
|
||||
cfg,
|
||||
&data_dir,
|
||||
&effective.inspections,
|
||||
lang_level,
|
||||
);
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(root, &effective).as_ref(),
|
||||
ide_sdk::apply::module_jdk(root, server_root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
|
||||
@@ -300,6 +300,7 @@ pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "plugin_assets"
|
||||
| "plugin_set_ui_locale"
|
||||
| "plugin_market"
|
||||
| "plugin_market_url"
|
||||
| "plugin_set_market_url"
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
//! The `commit` interface and the `commit-checker` export, through the
|
||||
//! `test.commit` fixture and a scripted window answering `CommitRequested`.
|
||||
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
use ide_host::plugins::*;
|
||||
use ide_plugin::{HostError, Invocation, Limits};
|
||||
use parking_lot::Mutex;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/commit")
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = Command::new("git")
|
||||
.current_dir(dir)
|
||||
.args(["-c", "init.defaultBranch=main", "-c", "user.name=T", "-c", "[email protected]"])
|
||||
.args(args)
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
struct Workspace {
|
||||
root: PathBuf,
|
||||
trusted: AtomicBool,
|
||||
}
|
||||
|
||||
impl PluginWorkspace for Workspace {
|
||||
fn projects(&self) -> Vec<PathBuf> {
|
||||
vec![self.root.clone()]
|
||||
}
|
||||
fn buffer_text(&self, _: &Path) -> Option<String> {
|
||||
None
|
||||
}
|
||||
fn document(&self, _: &Path) -> Option<(u64, String)> {
|
||||
None
|
||||
}
|
||||
fn apply_edits(&self, _: &Path, _: u64, _: &[ide_core::TextEdit]) -> Result<u64, HostError> {
|
||||
Err(HostError::NotFound("not open".into()))
|
||||
}
|
||||
fn trusted(&self, _: &Path) -> bool {
|
||||
self.trusted.load(Ordering::Relaxed)
|
||||
}
|
||||
fn project_of(&self, _: &str) -> Option<PathBuf> {
|
||||
Some(self.root.clone())
|
||||
}
|
||||
fn publish_diagnostics(&self, _: &str, _: &Path, _: Vec<ide_core::Diagnostic>) -> Result<(), HostError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The window's Commit view: its draft, and whether the user types between
|
||||
/// the plugin reading it and writing the message.
|
||||
#[derive(Default)]
|
||||
struct Window {
|
||||
revision: u64,
|
||||
message: String,
|
||||
selection: Option<CommitSelection>,
|
||||
type_after_reading: bool,
|
||||
requests: Vec<CommitRequest>,
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
host: Arc<PluginHost>,
|
||||
window: Arc<Mutex<Window>>,
|
||||
workspace: Arc<Workspace>,
|
||||
project: PathBuf,
|
||||
_tmp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
std::fs::create_dir_all(&project).unwrap();
|
||||
git(&project, &["init", "-q"]);
|
||||
std::fs::write(project.join("a.txt"), "one\ntwo\nthree\n").unwrap();
|
||||
git(&project, &["add", "."]);
|
||||
git(&project, &["commit", "-qm", "base"]);
|
||||
std::fs::write(project.join("a.txt"), "one\nDO NOT COMMIT\nthree\n").unwrap();
|
||||
|
||||
let window = Arc::new(Mutex::new(Window { revision: 7, ..Window::default() }));
|
||||
let host_slot: Arc<Mutex<Option<std::sync::Weak<PluginHost>>>> = Arc::default();
|
||||
let (w, slot) = (window.clone(), host_slot.clone());
|
||||
// Answers on another thread, as the UI does through `plugin_answer_commit`.
|
||||
let sink = Arc::new(move |_: Option<&str>, e: PluginEvent| {
|
||||
let PluginEvent::CommitRequested { request_id, request, .. } = e else { return };
|
||||
let (w, slot) = (w.clone(), slot.clone());
|
||||
std::thread::spawn(move || {
|
||||
let answer = {
|
||||
let mut w = w.lock();
|
||||
w.requests.push(request.clone());
|
||||
match request {
|
||||
CommitRequest::Selection => {
|
||||
let selection = w.selection.clone();
|
||||
if w.type_after_reading {
|
||||
w.revision += 1;
|
||||
}
|
||||
match selection {
|
||||
Some(s) => CommitAnswer::Selection { selection: s },
|
||||
None => CommitAnswer::NoRepository,
|
||||
}
|
||||
}
|
||||
CommitRequest::SetMessage { revision, message } if revision == w.revision => {
|
||||
w.message = message;
|
||||
w.revision += 1;
|
||||
CommitAnswer::Done
|
||||
}
|
||||
CommitRequest::SetMessage { .. } => CommitAnswer::Stale,
|
||||
}
|
||||
};
|
||||
let host = slot.lock().as_ref().and_then(std::sync::Weak::upgrade);
|
||||
assert!(host.unwrap().answer_commit(&request_id, answer));
|
||||
});
|
||||
});
|
||||
let host = Arc::new(PluginHost::new(tmp.path().join("plugins"), sink, Limits::default()).unwrap());
|
||||
*host_slot.lock() = Some(Arc::downgrade(&host));
|
||||
let workspace = Arc::new(Workspace { root: project.clone(), trusted: AtomicBool::new(true) });
|
||||
host.set_workspace(Some(workspace.clone()));
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
Self { host, window, workspace, project, _tmp: tmp }
|
||||
}
|
||||
|
||||
fn inv(&self) -> Invocation {
|
||||
Invocation { client: Some("main".into()), workspace_root: Some(self.project.clone()), ..Invocation::default() }
|
||||
}
|
||||
|
||||
/// `a.txt` included whole, with `message`.
|
||||
fn selection(&self, message: &str) -> CommitSelection {
|
||||
let revision = self.window.lock().revision;
|
||||
CommitSelection {
|
||||
revision,
|
||||
message: message.into(),
|
||||
amend: false,
|
||||
repositories: vec![CommitSelectionRepo {
|
||||
root: self.project.clone().into(),
|
||||
options: ide_vcs::CommitOptions {
|
||||
stage: vec![self.project.join("a.txt").into()],
|
||||
run_hooks: true,
|
||||
..Default::default()
|
||||
},
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn markers(&self, message: &str) -> Vec<PluginCommitCheckResult> {
|
||||
let check = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "markers".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
self.host.commit_checks(self.inv(), self.selection(message), vec![check], &cancel).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_command_reads_the_pending_commit_and_writes_its_message() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
f.window.lock().selection = Some(selection);
|
||||
let out = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap();
|
||||
assert_eq!(out.as_deref(), Some("chore: modified a.txt"));
|
||||
let w = f.window.lock();
|
||||
assert_eq!(w.message, "chore: modified a.txt");
|
||||
assert_eq!(w.revision, 8);
|
||||
// Reading the draft left the index alone: nothing is staged.
|
||||
let staged = Command::new("git").current_dir(&f.project).args(["diff", "--cached", "--name-only"]).output();
|
||||
assert!(staged.unwrap().stdout.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_meanwhile_makes_the_write_stale() {
|
||||
let f = Fixture::new();
|
||||
let selection = f.selection("");
|
||||
{
|
||||
let mut w = f.window.lock();
|
||||
w.selection = Some(selection);
|
||||
w.type_after_reading = true;
|
||||
}
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("stale") || err.message.contains("changed"), "{}", err.message);
|
||||
assert_eq!(f.window.lock().message, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_repository_and_restricted_mode() {
|
||||
let f = Fixture::new();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no repository"), "{}", err.message);
|
||||
|
||||
f.workspace.trusted.store(false, Ordering::Relaxed);
|
||||
f.window.lock().requests.clear();
|
||||
let err = f.host.run_command("test.commit", "describe", None, f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("Restricted Mode"), "{}", err.message);
|
||||
assert!(f.window.lock().requests.is_empty(), "the window is not asked");
|
||||
|
||||
// Outside a window's command there is no Commit view to ask.
|
||||
let headless = Invocation { client: None, ..f.inv() };
|
||||
let err = f.host.run_command("test.commit", "describe", None, headless).unwrap_err();
|
||||
assert!(err.message.contains("from a command"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checks_report_the_message_and_markers_in_the_included_changes() {
|
||||
let f = Fixture::new();
|
||||
let results = f.markers("wip");
|
||||
assert_eq!(results.len(), 1);
|
||||
let r = &results[0];
|
||||
assert_eq!((r.title.as_str(), r.error.as_deref()), ("Commit Markers", None));
|
||||
let found: Vec<_> =
|
||||
r.problems.iter().map(|p| (p.message.as_str(), p.path.clone(), p.range.map(|r| r.start.line))).collect();
|
||||
assert_eq!(
|
||||
found,
|
||||
[
|
||||
("The message does not start with `type: `.", None, None),
|
||||
("Remove the DO NOT COMMIT marker.", Some(to_ipc(&f.project.join("a.txt"))), Some(1)),
|
||||
]
|
||||
);
|
||||
assert_eq!(f.markers("fix: drop marker")[0].problems.len(), 1);
|
||||
|
||||
// Unknown checks are not run, nor those of a plugin that lost `vcs.read`.
|
||||
let other = PluginCommitCheckRef { plugin_id: "test.commit".into(), check: "other".into() };
|
||||
let cancel = ide_core::CancellationToken::new();
|
||||
assert!(f.host.commit_checks(f.inv(), f.selection("x"), vec![other], &cancel).unwrap().is_empty());
|
||||
f.host.set_capability("test.commit", "vcs.read", false).unwrap();
|
||||
assert!(f.markers("wip").iter().all(|r| r.problems.is_empty()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_commit_points_need_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host =
|
||||
PluginHost::new(tmp.path().join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
|
||||
// The manifest surgery below matches on `\n`; normalize so a CRLF checkout (Windows) behaves the same.
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap().replace("\r\n", "\n");
|
||||
for (what, manifest) in [
|
||||
(
|
||||
"commit.message",
|
||||
manifest.replace("[[contributes.commitChecks]]\nid = \"markers\"\ntitle = \"Commit Markers\"\n", ""),
|
||||
),
|
||||
(
|
||||
"commitChecks",
|
||||
manifest.replace("[[contributes.menus]]\nmenu = \"commit.message\"\ncommand = \"describe\"\n", ""),
|
||||
),
|
||||
] {
|
||||
let pkg = tmp.path().join(what);
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.2\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3") && err.message.contains(what), "{what}: {}", err.message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use ide_core::{Pos, Range};
|
||||
use ide_host::plugins::{Consent, PluginHost};
|
||||
use ide_plugin::{Limits, ProviderDocument};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn fixture_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/inlay-hints")
|
||||
}
|
||||
|
||||
fn host(dir: &Path) -> PluginHost {
|
||||
let limits = Limits { edit_soft: Duration::from_secs(5), edit_hard: Duration::from_secs(10), ..Limits::default() };
|
||||
PluginHost::new(dir.join("plugins"), Arc::new(|_: Option<&str>, _| {}), limits).unwrap()
|
||||
}
|
||||
|
||||
fn doc(dir: &Path, language: &str, text: &str) -> ProviderDocument {
|
||||
ProviderDocument { path: dir.join("Order.php"), language: language.into(), version: 1, text: text.into() }
|
||||
}
|
||||
|
||||
const ORDER: &str = "<?php\nclass Order\n{\n public function user(): ?User {}\n}\n$order->user();\n$order->total();\n$order->user;\n";
|
||||
|
||||
fn all() -> Range {
|
||||
Range::new(Pos::new(0, 0), Pos::new(100, 0))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calls_of_declared_methods_get_a_type_hint() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
let hints = host.provide_inlay_hints(&doc(tmp.path(), "php", ORDER), all());
|
||||
let found: Vec<_> =
|
||||
hints.iter().map(|h| (h.position.line, h.position.col, h.label.as_str(), h.kind.as_str())).collect();
|
||||
assert_eq!(found, [(5, 14, ": ?User", "type")]);
|
||||
assert_eq!(hints[0].tooltip.as_deref(), Some("user() returns ?User"));
|
||||
assert!(!hints[0].padding_left && !hints[0].padding_right);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_declared_languages_are_asked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
host.install_from_dir(&fixture_dir(), &Consent::all()).unwrap();
|
||||
assert!(host.provide_inlay_hints(&doc(tmp.path(), "javascript", ORDER), all()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kind_needs_plugin_api_1_3() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let host = host(tmp.path());
|
||||
let pkg = tmp.path().join("old");
|
||||
std::fs::create_dir_all(pkg.join("dist")).unwrap();
|
||||
std::fs::copy(fixture_dir().join("dist/plugin.wasm"), pkg.join("dist/plugin.wasm")).unwrap();
|
||||
let manifest = std::fs::read_to_string(fixture_dir().join("plugin.toml")).unwrap();
|
||||
std::fs::write(pkg.join("plugin.toml"), manifest.replace("\"^1.3\"", "\"^1.0\"")).unwrap();
|
||||
let err = host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains("new in plugin API 1.3"), "{}", err.message);
|
||||
}
|
||||
@@ -361,6 +361,9 @@ fn read_file_is_confined_to_the_project() {
|
||||
let err = f.run("misbehave", "read", Some("src/../../secret.txt")).unwrap_err();
|
||||
assert!(err.contains("outside the project root"), "{err}");
|
||||
assert!(f.run("misbehave", "read", Some("/etc/hosts")).is_err());
|
||||
// A missing path inside the project is `not-found`, like the mock reports.
|
||||
let err = f.run("misbehave", "read", Some("src/missing.txt")).unwrap_err();
|
||||
assert!(err.contains("not found"), "{err}");
|
||||
// Without a project there is nothing to read.
|
||||
let inv = Invocation::default();
|
||||
let err = f.host.run_command("misbehave", "read", Some("src/notes.txt"), inv).unwrap_err().message;
|
||||
@@ -1528,8 +1531,8 @@ fn official_key_promoter() {
|
||||
assert_eq!(tips.len(), 1, "{tips:?}");
|
||||
assert_eq!(tips[0].0.as_deref(), Some("main"), "shown in the window the actions came from");
|
||||
assert!(tips[0].1.contains("Ctrl+D") && tips[0].2.contains("3 times"), "{tips:?}");
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.key-promoter/storage.json")).unwrap();
|
||||
assert!(stored.contains("\"count:code.duplicate\":\"3\""), "{stored}");
|
||||
let stored = stored(&f, "openidea.key-promoter");
|
||||
assert!(stored.contains(&("count:code.duplicate".into(), "3".into())), "{stored:?}");
|
||||
|
||||
// Uninstalling removes its data.
|
||||
f.host.uninstall("openidea.key-promoter").unwrap();
|
||||
@@ -1537,6 +1540,43 @@ fn official_key_promoter() {
|
||||
assert!(f.host.event_kinds().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn storage_keeps_multimegabyte_indexes_after_idle_reclaim_and_removes_them_on_uninstall() {
|
||||
let f = fixture(Limits { idle_timeout: Some(Duration::from_millis(100)), ..Limits::default() });
|
||||
let pkg = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/storage");
|
||||
f.install(&pkg);
|
||||
f.run("test.storage", "save", None).unwrap();
|
||||
assert_eq!(f.host.instances(), 1);
|
||||
std::thread::sleep(Duration::from_millis(150));
|
||||
f.host.tick();
|
||||
assert_eq!(f.host.instances(), 0);
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.reclaims, 1);
|
||||
assert_eq!(f.run("test.storage", "load", None).unwrap().as_deref(), Some("4194304"));
|
||||
assert_eq!(f.host.info_of("test.storage").unwrap().stats.activations, 2);
|
||||
let storage_dir = f.plugins_dir.join(".data/test.storage");
|
||||
assert_eq!(std::fs::read_dir(storage_dir.join("storage")).unwrap().count(), 1, "one file per key");
|
||||
f.host.uninstall("test.storage").unwrap();
|
||||
assert!(!storage_dir.exists());
|
||||
f.install(&pkg);
|
||||
assert!(f.run("test.storage", "load", None).unwrap_err().contains("missing index"));
|
||||
}
|
||||
|
||||
/// A plugin's stored entries (key, value), read from its files.
|
||||
fn stored(f: &Fixture, plugin: &str) -> Vec<(String, String)> {
|
||||
let dir = f.plugins_dir.join(".data").join(plugin).join("storage");
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.map(|e| {
|
||||
let bytes = std::fs::read(e.unwrap().path()).unwrap();
|
||||
let len = usize::from(u16::from_le_bytes([bytes[0], bytes[1]]));
|
||||
let key = String::from_utf8(bytes[2..2 + len].to_vec()).unwrap();
|
||||
(key, String::from_utf8(bytes[2 + len..].to_vec()).unwrap())
|
||||
})
|
||||
.collect();
|
||||
entries.sort();
|
||||
entries
|
||||
}
|
||||
|
||||
fn git(dir: &Path, args: &[&str]) {
|
||||
let out = std::process::Command::new("git")
|
||||
.args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "init.defaultBranch=main"])
|
||||
@@ -1601,8 +1641,8 @@ fn official_git_toolbox_fetches_on_schedule_after_idle_reclaim() {
|
||||
)
|
||||
);
|
||||
assert!(events.iter().any(|(_, e)| matches!(e, PluginEvent::TaskEnded { .. })));
|
||||
let stored = std::fs::read_to_string(f.plugins_dir.join(".data/openidea.git-toolbox/storage.json")).unwrap();
|
||||
assert!(stored.contains("last-fetch:"), "{stored}");
|
||||
let stored = stored(&f, "openidea.git-toolbox");
|
||||
assert!(stored.iter().any(|(key, _)| key.starts_with("last-fetch:")), "{stored:?}");
|
||||
}
|
||||
|
||||
/// Restricted Mode: git does not run in an untrusted project (no fetch).
|
||||
@@ -1940,6 +1980,35 @@ fn official_csv_editor_edits_the_shared_document() {
|
||||
assert_eq!(err.kind, ide_core::error::ErrorKind::NotFound);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_menu_entries_can_name_a_submenu() {
|
||||
fn with_menu(version: &'static str, extra: &'static str) -> impl Fn(&str) -> String {
|
||||
move |m: &str| {
|
||||
let m = m.replace("engines.openidea = \"^1.0\"", &format!("engines.openidea = \"{version}\""));
|
||||
format!("{m}\n[[contributes.menus]]\ncommand = \"word-count\"\n{extra}\n")
|
||||
}
|
||||
}
|
||||
let f = fixture(Limits::default());
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let ok = hello_variant(dir.path(), with_menu("^1.3", "menu = \"editor.context\"\nsubmenu = \"Words\""));
|
||||
assert_eq!(f.install(&ok).status, PluginStatus::Inactive);
|
||||
let submenus: Vec<_> =
|
||||
f.host.ui_items("menus").into_iter().filter_map(|i| i.decl["submenu"].as_str().map(str::to_owned)).collect();
|
||||
assert_eq!(submenus, ["Words"]);
|
||||
f.host.uninstall("hello").unwrap();
|
||||
|
||||
for (version, extra, message) in [
|
||||
("^1.3", "menu = \"tools\"\nsubmenu = \"Words\"", "only for context menus"),
|
||||
("^1.3", "menu = \"editor.context\"\nsubmenu = \" \"", "titles are"),
|
||||
("^1.2", "menu = \"editor.context\"\nsubmenu = \"Words\"", "1.3"),
|
||||
] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let pkg = hello_variant(dir.path(), with_menu(version, extra));
|
||||
let err = f.host.install_from_dir(&pkg, &Consent::all()).unwrap_err();
|
||||
assert!(err.message.contains(message), "{extra}: {}", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_item_menus_name_a_declared_view() {
|
||||
/// The menu is new in API 1.2.
|
||||
@@ -2045,6 +2114,45 @@ fn sample_plugin_tool_window_with_a_tree() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The tables fixture sorts its own rows: a `table` body sent through `other`
|
||||
/// opts the headers in, and a header click leaves as an `other` view-event
|
||||
/// (kind `sort`) the plugin decodes and answers with the rows in the asked
|
||||
/// order (plugins/README.md §10: released types stay, `other` carries new
|
||||
/// cases).
|
||||
#[test]
|
||||
fn sortable_plugin_table_round_trips_sort_events() {
|
||||
let f = fixture(Limits::default());
|
||||
let info = f.install(&sample("tables"));
|
||||
assert_eq!(info.status, PluginStatus::Inactive, "{:?}", info.error);
|
||||
|
||||
let table = |f: &Fixture| match f.host.render_view("tables", "tables", None, f.inv()).unwrap().body {
|
||||
ide_plugin::ViewBody::Table(t) => t,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
// No sort yet: headers are clickable, rows in the plugin's own order.
|
||||
let t = table(&f);
|
||||
assert_eq!((t.columns.clone(), t.total_rows), (vec!["ext".to_owned(), "files".into()], 4));
|
||||
assert_eq!((t.sortable, t.sort), (true, None), "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.rows.iter().map(|r| r[0].clone()).collect::<Vec<_>>(), ["rs", "md", "java", "toml"]);
|
||||
|
||||
f.take_events();
|
||||
let sort = |f: &Fixture, descending| {
|
||||
f.host.view_event("tables", "tables", ide_plugin::ViewEvent::Sort { column: 1, descending }, f.inv()).unwrap();
|
||||
assert!(f.take_events().into_iter().any(
|
||||
|(_, e)| matches!(e, PluginEvent::ViewChanged { ref plugin_id, ref view } if plugin_id == "tables" && view == "tables")
|
||||
));
|
||||
table(f)
|
||||
};
|
||||
|
||||
// The plugin reports the sort it ordered by, with the rows to match.
|
||||
let t = sort(&f, false);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: false }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["3", "9", "12", "20"]);
|
||||
let t = sort(&f, true);
|
||||
assert_eq!(t.sort, Some(ide_plugin::TableSort { column: 1, descending: true }));
|
||||
assert_eq!(t.rows.iter().map(|r| r[1].clone()).collect::<Vec<_>>(), ["20", "12", "9", "3"]);
|
||||
}
|
||||
|
||||
/// Regex Tester: an `html` body its script talks to; the plugin answers in
|
||||
/// view messages, in UTF-16 offsets.
|
||||
#[test]
|
||||
@@ -2270,9 +2378,14 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input", Some("Main"), Some(&["Other"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("Other"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()) });
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("Main".into()), multiline: false });
|
||||
assert_eq!(run_and_answer(&f, "input", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "input-multiline", Some("one\ntwo"), Some(&["a\nb"]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("a\nb"));
|
||||
assert_eq!(prompt, Prompt::Input { prompt: "Name".into(), value: Some("one\ntwo".into()), multiline: true });
|
||||
assert_eq!(run_and_answer(&f, "input-multiline", None, None, none).0.unwrap().as_deref(), Some("none"));
|
||||
|
||||
let (result, prompt) = run_and_answer(&f, "confirm", None, Some(&[]), none);
|
||||
assert_eq!(result.unwrap().as_deref(), Some("true"));
|
||||
assert_eq!(prompt, Prompt::Confirm { title: "Delete".into(), message: "Really?".into() });
|
||||
@@ -2291,7 +2404,7 @@ fn commands_ask_the_user_and_reveal_their_views() {
|
||||
// Without a window there is nothing to attach the dialog to.
|
||||
f.take_events();
|
||||
let no_window = Invocation { client: None, ..f.inv() };
|
||||
for cmd in ["pick", "input", "confirm"] {
|
||||
for cmd in ["pick", "input", "input-multiline", "confirm"] {
|
||||
let err = f.host.run_command("jobs", cmd, None, no_window.clone()).unwrap_err().message;
|
||||
assert!(err.contains("from a command the user invoked"), "{cmd}: {err}");
|
||||
}
|
||||
@@ -2379,6 +2492,46 @@ web = "panel/index.html"
|
||||
assert!(assets[0].1.contains("<rect"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_package_ships_translations_in_locales() {
|
||||
let f = fixture(Limits::default());
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let package = |id: &str, catalogs: &[(&str, &str)]| {
|
||||
let pkg = tmp.path().join(id);
|
||||
std::fs::create_dir_all(pkg.join("locales")).unwrap();
|
||||
for (name, text) in catalogs {
|
||||
std::fs::write(pkg.join("locales").join(name), text).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
pkg.join("plugin.toml"),
|
||||
format!(
|
||||
"id = \"{id}\"\nname = \"Words\"\nversion = \"1.0.0\"\nengines.openidea = \"^1.0\"\n\n\
|
||||
[[contributes.commands]]\nid = \"count\"\ntitle = \"Count Words\"\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
pkg
|
||||
};
|
||||
|
||||
for bad in ["{", "[]", r#"{"Count Words": 1}"#] {
|
||||
let pkg = package("bad-catalog", &[("fr.json", bad)]);
|
||||
assert!(f.host.install_from_dir(&pkg, &Consent::all()).is_err(), "{bad}");
|
||||
}
|
||||
|
||||
let pkg = package("words", &[("fr.json", r#"{"Count Words": "Compter les mots"}"#), ("zh-CN.json", "{}")]);
|
||||
let info = f.install(&pkg);
|
||||
assert!(info.error.is_none(), "{:?}", info.error);
|
||||
let item = f.host.ui_items("locales").into_iter().find(|i| i.plugin_id == "words").expect("the catalogs");
|
||||
assert_eq!(item.decl["tags"], serde_json::json!(["fr", "zh-CN"]));
|
||||
let assets = f.host.assets("words", "locale/fr").unwrap();
|
||||
assert_eq!(assets.len(), 1);
|
||||
assert!(assets[0].1.contains("Compter les mots"));
|
||||
|
||||
let other = f.install(&package("other", &[]));
|
||||
assert!(other.error.is_none(), "{:?}", other.error);
|
||||
assert!(f.host.assets("other", "locale/").unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A plugin with web views and no code: its pages and their files are
|
||||
/// installed with it and served only from the declared directories, while
|
||||
/// it is enabled and only for the installed version; file icons and
|
||||
@@ -2798,6 +2951,30 @@ fn completion_quick_fixes_problems_and_event_kinds_of_api_1() {
|
||||
let foreign = ide_plugin::CodeAction { data: Some("other".into()), ..action };
|
||||
assert!(f.host.resolve_code_action(&plugin, &doc("lazy"), foreign).is_err());
|
||||
|
||||
// File operations come from the `file-actions` export, next to the providers' own.
|
||||
let mut actions = f.host.provide_code_actions(&doc("files"), ide_core::Range::default()).into_iter();
|
||||
let (_, action) = actions.next().expect("offered");
|
||||
let (plugin, later) = actions.next().expect("a lazy one too");
|
||||
let dir = f.project.clone();
|
||||
assert_eq!(action.title, "Reorganise");
|
||||
assert_eq!(
|
||||
action.operations,
|
||||
vec![
|
||||
ide_plugin::FileOperation::Create { path: dir.join("created.txt"), text: "hello".into(), overwrite: false },
|
||||
ide_plugin::FileOperation::Rename { from: dir.join("old.txt"), to: dir.join("renamed.txt") },
|
||||
ide_plugin::FileOperation::Delete { path: dir.join("gone.txt") },
|
||||
]
|
||||
);
|
||||
assert_eq!(action.open, Some(dir.join("created.txt")));
|
||||
// Listed with `data` only: `resolve` on the same export computes its operations.
|
||||
assert!(later.file_action && later.operations.is_empty() && later.data.is_some());
|
||||
let resolved = f.host.resolve_code_action(&plugin, &doc("files"), later).unwrap();
|
||||
assert_eq!(
|
||||
resolved.operations,
|
||||
vec![ide_plugin::FileOperation::Create { path: dir.join("later.txt"), text: "later".into(), overwrite: false }]
|
||||
);
|
||||
assert!(f.host.provide_code_actions(&doc("rich"), ide_core::Range::default()).is_empty());
|
||||
|
||||
// A kind this IDE doesn't know is refused, not a trap.
|
||||
let err = f.host.run_command("misbehave", "subscribe", Some("file-deleted"), f.inv()).unwrap_err();
|
||||
assert!(err.message.contains("no event kind"), "{}", err.message);
|
||||
|
||||
@@ -164,6 +164,9 @@ async fn remote_projects_run_the_desktops_code_plugins_on_the_server() {
|
||||
let kinds = invoke(&peer, "plugin_event_kinds", json!({})).await.unwrap();
|
||||
assert!(kinds.as_array().unwrap().contains(&json!("file-saved")), "{kinds}");
|
||||
|
||||
invoke(&peer, "plugin_set_ui_locale", json!({ "tag": "fr" })).await.unwrap();
|
||||
assert_eq!(plugins.host().ui_locale(), "fr");
|
||||
|
||||
// Saving a file that exists only on the server: Checkstyle reads that file.
|
||||
let java = project.join("src/A.java");
|
||||
std::fs::write(&java, "class A {\n\tint x;\n}\n").unwrap();
|
||||
|
||||
+228
-5
@@ -41,6 +41,12 @@ const ACCESS_OPTIONS: [&str; 2] = [
|
||||
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
|
||||
];
|
||||
|
||||
const LEVEL_OPTIONS: [&str; 3] = [
|
||||
"org.eclipse.jdt.core.compiler.source",
|
||||
"org.eclipse.jdt.core.compiler.compliance",
|
||||
"org.eclipse.jdt.core.compiler.codegen.targetPlatform",
|
||||
];
|
||||
|
||||
/// Keep the source model in protocol settings so jdtls's invisible-project
|
||||
/// initialization and configuration use the same roots as workspace builds.
|
||||
pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, paths: Option<Vec<String>>) {
|
||||
@@ -54,6 +60,115 @@ pub fn configure_source_paths(cfg: &mut ide_language::LanguageServerConfig, path
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies Project Structure compiler output path, project libraries (and source attachments),
|
||||
/// and Web facet `WEB-INF/lib` directories to jdtls `java.project.*` settings.
|
||||
pub fn configure_project_structure(
|
||||
cfg: &mut ide_language::LanguageServerConfig,
|
||||
root: &Path,
|
||||
server_root: &Path,
|
||||
structure: &ide_core::settings::ProjectStructureSettings,
|
||||
) {
|
||||
if cfg.name != SERVER {
|
||||
return;
|
||||
}
|
||||
let module_cfg = structure.module_settings(root, server_root);
|
||||
let module_output = module_cfg.map(|m| m.compiler_output.trim()).filter(|s| !s.is_empty());
|
||||
let output_path = if let Some(output) = module_output {
|
||||
// The dialog stores paths relative to the project, JDT expects the server root.
|
||||
let path = root.join(output);
|
||||
match path.strip_prefix(server_root) {
|
||||
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
|
||||
Err(_) => {
|
||||
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {}", path.display()));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let output = structure.compiler_output.trim();
|
||||
if output.is_empty() {
|
||||
None
|
||||
} else if Path::new(output).is_absolute() {
|
||||
match Path::new(output).strip_prefix(server_root) {
|
||||
Ok(relative) => Some(relative.to_string_lossy().replace('\\', "/")),
|
||||
Err(_) => {
|
||||
cfg.launch_error = Some(format!("Java compiler output must be inside its module: {output}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Some(output.replace('\\', "/"))
|
||||
}
|
||||
};
|
||||
|
||||
let normalize = |entry: &str| -> Option<String> {
|
||||
let trimmed = entry.trim();
|
||||
if trimmed.is_empty() {
|
||||
return None;
|
||||
}
|
||||
// JDT resolves relative library globs against its own root, which may be a submodule.
|
||||
Some(root.join(trimmed).to_string_lossy().replace('\\', "/"))
|
||||
};
|
||||
let mut include: Vec<String> = Vec::new();
|
||||
let mut sources = serde_json::Map::new();
|
||||
for lib in &structure.libraries {
|
||||
let class_entries: Vec<String> = lib.classes.iter().filter_map(|c| normalize(c)).collect();
|
||||
let source_entries: Vec<String> = lib.sources.iter().filter_map(|s| normalize(s)).collect();
|
||||
for c in &class_entries {
|
||||
if !include.contains(c) {
|
||||
include.push(c.clone());
|
||||
}
|
||||
}
|
||||
for (class, source) in class_entries.iter().zip(&source_entries) {
|
||||
sources.insert(class.clone(), json!(source));
|
||||
}
|
||||
}
|
||||
for facet in &structure.facets {
|
||||
if facet.kind == "web" && root.join(&facet.module) == server_root {
|
||||
let web_root = facet.web_root.trim();
|
||||
let web_root = if web_root.is_empty() { "src/main/webapp" } else { web_root };
|
||||
if let Some(path) = normalize(web_root) {
|
||||
let glob = format!("{}/WEB-INF/lib/**/*.jar", path.trim_end_matches('/'));
|
||||
if !include.contains(&glob) {
|
||||
include.push(glob);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if output_path.is_none() && include.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
if let Some(output) = output_path {
|
||||
settings["java"]["project"]["outputPath"] = json!(output);
|
||||
}
|
||||
if !include.is_empty() {
|
||||
let existing = &settings["java"]["project"]["referencedLibraries"];
|
||||
let mut libraries = if existing.is_object() {
|
||||
existing.clone()
|
||||
} else if existing.is_array() {
|
||||
json!({ "include": existing })
|
||||
} else {
|
||||
json!({ "include": ["lib/**/*.jar"] })
|
||||
};
|
||||
let mut entries = libraries["include"].as_array().cloned().unwrap_or_default();
|
||||
for path in include {
|
||||
let path = json!(path);
|
||||
if !entries.contains(&path) {
|
||||
entries.push(path);
|
||||
}
|
||||
}
|
||||
libraries["include"] = json!(entries);
|
||||
if !sources.is_empty() {
|
||||
let mut attachments = libraries["sources"].as_object().cloned().unwrap_or_default();
|
||||
attachments.extend(sources);
|
||||
libraries["sources"] = json!(attachments);
|
||||
}
|
||||
settings["java"]["project"]["referencedLibraries"] = libraries;
|
||||
}
|
||||
cfg.settings = Some(settings);
|
||||
}
|
||||
|
||||
pub(crate) fn source_paths_removed(
|
||||
current: &ide_language::LanguageServerConfig,
|
||||
next: &ide_language::LanguageServerConfig,
|
||||
@@ -104,11 +219,13 @@ fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
|
||||
let mut options = serde_json::Map::new();
|
||||
for line in prefs.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& ACCESS_OPTIONS.contains(&key.trim())
|
||||
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
|
||||
{
|
||||
options.insert(key.trim().into(), json!(value.trim()));
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let (k, v) = (key.trim(), value.trim());
|
||||
if (ACCESS_OPTIONS.contains(&k) && ["error", "warning", "info", "ignore"].contains(&v))
|
||||
|| (LEVEL_OPTIONS.contains(&k) && ["1.8", "11", "17", "21", "25"].contains(&v))
|
||||
{
|
||||
options.insert(k.into(), json!(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
@@ -978,4 +1095,110 @@ mod tests {
|
||||
assert!(missing_bundle(&IdeError::invalid("No delegateCommandHandler for vscode.java.startDebugSession")));
|
||||
assert!(!missing_bundle(&e));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configure_project_structure_sets_output_referenced_libraries_and_web_facets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let lib_dir = root.join("lib");
|
||||
std::fs::create_dir_all(&lib_dir).unwrap();
|
||||
let jar_path = lib_dir.join("helper.jar");
|
||||
let src_path = lib_dir.join("helper-sources.jar");
|
||||
std::fs::write(&jar_path, b"").unwrap();
|
||||
std::fs::write(&src_path, b"").unwrap();
|
||||
|
||||
let mut structure = ide_core::settings::ProjectStructureSettings {
|
||||
compiler_output: "out/production".into(),
|
||||
libraries: vec![ide_core::settings::ProjectLibrary {
|
||||
name: "helper".into(),
|
||||
classes: vec!["lib/helper.jar".into()],
|
||||
sources: vec!["lib/helper-sources.jar".into()],
|
||||
javadocs: vec![],
|
||||
scope: ide_core::settings::DependencyScope::Compile,
|
||||
}],
|
||||
facets: vec![ide_core::settings::FacetConfig {
|
||||
kind: "web".into(),
|
||||
module: ".".into(),
|
||||
descriptor: "src/main/webapp/WEB-INF/web.xml".into(),
|
||||
web_root: "src/main/webapp".into(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
structure.modules.insert(
|
||||
"submod".into(),
|
||||
ide_core::settings::ModuleStructureSettings {
|
||||
compiler_output: "submod/classes".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
configure_project_structure(&mut cfg, root, root, &structure);
|
||||
|
||||
let settings = cfg.settings.as_ref().unwrap();
|
||||
assert_eq!(settings.pointer("/java/project/outputPath").and_then(Value::as_str), Some("out/production"));
|
||||
let include = settings
|
||||
.pointer("/java/project/referencedLibraries/include")
|
||||
.and_then(Value::as_array)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.collect::<Vec<_>>();
|
||||
assert!(include.iter().any(|p| p.ends_with("lib/helper.jar")));
|
||||
assert!(include.iter().any(|p| p.ends_with("src/main/webapp/WEB-INF/lib/**/*.jar")));
|
||||
let sources = settings.pointer("/java/project/referencedLibraries/sources").and_then(Value::as_object).unwrap();
|
||||
assert!(sources.values().any(|v| v.as_str().is_some_and(|s| s.ends_with("lib/helper-sources.jar"))));
|
||||
|
||||
// Module-specific compiler output overrides project-wide compiler output.
|
||||
let mut sub_cfg: ide_language::LanguageServerConfig =
|
||||
toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
configure_project_structure(&mut sub_cfg, root, &root.join("submod"), &structure);
|
||||
assert_eq!(
|
||||
sub_cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath").and_then(Value::as_str),
|
||||
Some("classes")
|
||||
);
|
||||
let sub_settings = sub_cfg.settings.as_ref().unwrap();
|
||||
let libs = sub_settings.pointer("/java/project/referencedLibraries/include").unwrap().as_array().unwrap();
|
||||
assert!(libs.contains(&json!(root.join("lib/helper.jar").to_string_lossy().replace('\\', "/"))));
|
||||
assert!(!libs.iter().any(|v| v.as_str().is_some_and(|p| p.contains("WEB-INF"))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_libraries_preserve_existing_globs_exclusions_and_source_attachments() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
cfg.settings = Some(json!({"java": {"project": {"referencedLibraries": {
|
||||
"include": ["vendor/**/*.jar"], "exclude": ["vendor/old.jar"],
|
||||
"sources": {"vendor/base.jar": "vendor/base-sources.jar"}
|
||||
}}}}));
|
||||
let structure = ide_core::settings::ProjectStructureSettings {
|
||||
libraries: vec![ide_core::settings::ProjectLibrary {
|
||||
classes: vec!["lib/new.jar".into()],
|
||||
sources: vec!["lib/new-sources.jar".into()],
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
let libraries = cfg.settings.as_ref().unwrap().pointer("/java/project/referencedLibraries").unwrap();
|
||||
assert_eq!(libraries["include"][0], "vendor/**/*.jar");
|
||||
assert_eq!(libraries["exclude"], json!(["vendor/old.jar"]));
|
||||
assert_eq!(libraries["sources"]["vendor/base.jar"], "vendor/base-sources.jar");
|
||||
assert_eq!(libraries["sources"].as_object().unwrap().len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_output_inside_the_module_becomes_relative() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
let mut structure = ide_core::settings::ProjectStructureSettings {
|
||||
compiler_output: tmp.path().join("out").to_string_lossy().into_owned(),
|
||||
..Default::default()
|
||||
};
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
assert_eq!(cfg.settings.as_ref().unwrap().pointer("/java/project/outputPath"), Some(&json!("out")));
|
||||
structure.compiler_output = tmp.path().parent().unwrap().join("elsewhere").to_string_lossy().into_owned();
|
||||
configure_project_structure(&mut cfg, tmp.path(), tmp.path(), &structure);
|
||||
assert!(cfg.launch_error.as_deref().unwrap().contains("inside its module"));
|
||||
}
|
||||
}
|
||||
+20
-16
@@ -1385,14 +1385,8 @@ impl LspManager {
|
||||
let mut first = Vec::new();
|
||||
if method == "textDocument/implementation" {
|
||||
let (locs, complete) = self.java_source_implementations(snap, t, pos, cancel).await;
|
||||
// A type declared in the sources has no implementations in the dependencies:
|
||||
// none found there is the whole answer, without a type hierarchy over every jar.
|
||||
if complete {
|
||||
if !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
out.value = Some(Vec::new());
|
||||
continue;
|
||||
if complete && !locs.is_empty() {
|
||||
return Ok(locs);
|
||||
}
|
||||
if let Some(partial) = partial
|
||||
&& !locs.is_empty()
|
||||
@@ -1450,9 +1444,9 @@ impl LspManager {
|
||||
self.references_streaming(doc, pos, include_declaration, project_wide, true, cancel, None).await
|
||||
}
|
||||
|
||||
/// [`Self::references`]. Java: without `libraries`, only the workspace's sources are
|
||||
/// searched (`libraries_skipped` when `project_wide`); with it and `project_wide`, those
|
||||
/// found there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
/// [`Self::references`]. Java, `project_wide`: without `libraries`, only the
|
||||
/// workspace's sources are searched (`libraries_skipped`); with it, those found
|
||||
/// there arrive on `partial` before the dependencies and the JDK are searched.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn references_streaming(
|
||||
&self,
|
||||
@@ -1481,7 +1475,20 @@ impl LspManager {
|
||||
};
|
||||
let native =
|
||||
self.native_references(doc, pos, include_declaration, project_wide, libraries, cancel.clone(), partial);
|
||||
let (configuration, result) = futures::join!(configuration, native);
|
||||
futures::pin_mut!(configuration, native);
|
||||
let (configuration, result) = match futures::future::select(configuration, native).await {
|
||||
futures::future::Either::Left((configuration, native)) => (configuration, native.await),
|
||||
futures::future::Either::Right((result, configuration)) => {
|
||||
// The configuration index is rebuilt after Java changes, which takes seconds in a
|
||||
// project with many dependencies: the usages found meanwhile are shown first.
|
||||
if let (Some(partial), Ok(found)) = (partial, &result)
|
||||
&& !found.locations.is_empty()
|
||||
{
|
||||
partial(found.clone());
|
||||
}
|
||||
(configuration.await, result)
|
||||
}
|
||||
};
|
||||
let configuration = configuration?;
|
||||
let mut result = result?;
|
||||
if let Some(configuration) = configuration {
|
||||
@@ -1605,10 +1612,7 @@ impl LspManager {
|
||||
};
|
||||
scope::Widened { first: all, second: Some(found), incomplete: sources.incomplete }
|
||||
} else {
|
||||
// Bulk probes (usage counts, Safe Delete) ask about the sources' own symbols,
|
||||
// which no dependency uses: searching every jar and the JDK only adds seconds
|
||||
// per symbol and delays the searches the user asks for.
|
||||
let first = query(!libraries).await?;
|
||||
let first = query(false).await?;
|
||||
let empty = first.is_empty();
|
||||
scope::Widened::only(first, empty, &t.server)
|
||||
};
|
||||
|
||||
@@ -1891,11 +1891,10 @@ async fn java_implementations_come_from_sources_first() {
|
||||
.collect::<Value>()
|
||||
};
|
||||
*s.responder.lock() = Some(Arc::new(move |_, method, params| match method {
|
||||
// Lines 0 and 1 are declared in the sources; 2 and 3 in a dependency, which implements 2 on line 3.
|
||||
// Line 0 is declared in the sources; line 2 in a dependency, which implements it on line 3.
|
||||
"workspace/executeCommand" => Some(match params["arguments"][1].as_u64() {
|
||||
Some(0) => json!({ "locations": at(&[1]), "complete": true }),
|
||||
Some(2) => json!({ "locations": at(&[1]), "complete": false }),
|
||||
Some(3) => json!({ "locations": [], "complete": false }),
|
||||
_ => json!({ "locations": [], "complete": true }),
|
||||
}),
|
||||
"textDocument/implementation" => Some(at(&[3, 1])),
|
||||
@@ -1926,14 +1925,7 @@ async fn java_implementations_come_from_sources_first() {
|
||||
assert_eq!(lines(&found), vec![3, 1]);
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// Declared in the sources and implemented nowhere there: no search through the dependencies.
|
||||
let found = h.mgr.goto(doc, Pos::new(1, 6), GotoKind::Implementation).await.unwrap();
|
||||
assert!(found.is_empty());
|
||||
let markers = h.mgr.implementations(doc, &[Pos::new(1, 6)], Default::default()).await.unwrap();
|
||||
assert!(markers[0].is_empty());
|
||||
assert_eq!(s.requested("textDocument/implementation").len(), 2);
|
||||
|
||||
// None in the sources of a dependency's type: the standard request, with the dependencies.
|
||||
// None in the sources: the standard request, with the dependencies.
|
||||
let partial = Mutex::new(Vec::new());
|
||||
let found = h
|
||||
.mgr
|
||||
@@ -3322,17 +3314,9 @@ async fn java_usages_in_the_sources_arrive_before_the_dependencies() {
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[3..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [Some(json!("sources")), Some(json!("sources"))]);
|
||||
// Scoped probes with the libraries search everything at once.
|
||||
// Scoped probes (code lenses) search everything at once.
|
||||
let scoped = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 2);
|
||||
// Usage counts and Safe Delete (no libraries): the sources alone, not widened.
|
||||
let scoped =
|
||||
h.mgr.references_streaming(doc, Pos::new(0, 5), true, false, false, Default::default(), None).await.unwrap();
|
||||
assert_eq!(scoped.locations.len(), 1);
|
||||
assert!(scoped.locations.iter().all(|l| l.uri.is_none()));
|
||||
let scopes: Vec<_> =
|
||||
s.requested("workspace/executeCommand")[5..].iter().map(|p| p["arguments"].get(4).cloned()).collect();
|
||||
assert_eq!(scopes, [None, Some(json!("sources"))]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
@@ -4144,6 +4128,52 @@ async fn configuration_usages_merge_native_and_unsaved_profiles_without_sibling_
|
||||
assert_eq!(refs.incomplete, None);
|
||||
}
|
||||
|
||||
/// The configuration index is rebuilt after Java changes, which can take seconds: Show Usages
|
||||
/// shows the Java usages meanwhile, then adds the configuration ones.
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn java_usages_arrive_before_configuration_usages() {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({"commands": ["openidea.java.usages", "openidea.java.configuration"]});
|
||||
let h = harness_for("java", "java", &[("jdtls", caps)], &[]);
|
||||
let s = h.launcher.state("jdtls");
|
||||
let yaml = h.root.join("application.yml");
|
||||
std::fs::write(&yaml, "app:\n target: 1\n").unwrap();
|
||||
let property = json!({"uri": uri_of(&yaml), "range": {
|
||||
"start": {"line": 1, "character": 2}, "end": {"line": 1, "character": 8}}});
|
||||
let gate = Arc::new(Gate::default());
|
||||
let held = gate.clone();
|
||||
*s.responder.lock() = Some(Arc::new(move |s, method, params| {
|
||||
(method == "workspace/executeCommand").then(|| {
|
||||
if params["command"] == "openidea.java.configuration" {
|
||||
held.hold();
|
||||
return json!({"locations": [property.clone()], "keys": [{"name": "app.target", "relaxed": true}], "roots": []});
|
||||
}
|
||||
let docs = s.docs.lock();
|
||||
let rows = docs.iter().flat_map(|(uri, text)| occurrences(uri, text));
|
||||
Value::Array(rows.map(|location| json!({"location": location, "kind": "unknown"})).collect())
|
||||
})
|
||||
}));
|
||||
let (doc, a) = h.open("a.java", "int target;");
|
||||
h.wait_status("jdtls", ServerStatus::Ready).await;
|
||||
eventually(|| s.docs.lock().len() == 1).await;
|
||||
let partials = Mutex::new(Vec::<References>::new());
|
||||
let push = |r| partials.lock().push(r);
|
||||
let search = h.mgr.references_streaming(doc, Pos::new(0, 5), true, true, false, Default::default(), Some(&push));
|
||||
let release = async {
|
||||
eventually(|| !partials.lock().is_empty()).await;
|
||||
assert!(gate.entered(), "shown before the configuration lookup started");
|
||||
gate.open();
|
||||
};
|
||||
let (refs, ()) = tokio::join!(search, release);
|
||||
let refs = refs.unwrap();
|
||||
let partials = partials.into_inner();
|
||||
assert_eq!(partials.len(), 1);
|
||||
let paths = |r: &References| r.locations.iter().map(|l| l.path.to_path_buf()).collect::<Vec<_>>();
|
||||
assert_eq!(paths(&partials[0]), std::slice::from_ref(&a));
|
||||
assert_eq!(partials[0].classified.len(), 1);
|
||||
assert_eq!(paths(&refs), [a, yaml]);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn configuration_extension_failure_preserves_native_java_navigation() {
|
||||
let mut caps = incremental_caps();
|
||||
|
||||
@@ -20,6 +20,6 @@ pub use runtime::{
|
||||
API_VERSION, CANCELLED, CallCancel, CallError, GuestError, HostApi, PluginEngine, Runtime, RuntimeSetup, check_api,
|
||||
component_api, host_api,
|
||||
};
|
||||
pub use sandbox::{MAX_READ_BYTES, read_text, resolve_in_root};
|
||||
pub use sandbox::{MAX_READ_BYTES, inside_root, read_text, resolve_in_root, resolve_new_in_root};
|
||||
pub use types::*;
|
||||
pub use wasmtime::component::Component;
|
||||
@@ -4,19 +4,20 @@
|
||||
//! themselves are in `wit_host`.
|
||||
|
||||
use crate::types::{
|
||||
CodeAction, CompletionItem, InlineSuggestion, LineMarker, Location, PluginHover, ProviderDocument, SemanticToken,
|
||||
CodeAction, CommitDraft, CommitProblem, CompletionItem, FileOperation, InlineSuggestion, LineMarker, Location,
|
||||
PluginHover, PluginInlayHint, ProviderDocument, SemanticToken,
|
||||
};
|
||||
use crate::types::{
|
||||
EventKind, HostError, HttpRequest, HttpResponse, Invocation, Limits, NotificationLevel, ProcessOutput, Prompt,
|
||||
RepositoryStatus, StatusItem, SyntaxFold, SyntaxSymbol, SyntaxToken, WorkbenchEvent,
|
||||
};
|
||||
use crate::types::{
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, View, ViewBody, ViewButton, ViewEvent,
|
||||
ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
FieldValue, ServiceLog, ServiceLogToggle, ServicePane, ServiceTab, TableSort, View, ViewBody, ViewButton,
|
||||
ViewEvent, ViewField, ViewListItem, ViewTable, ViewTreeItem, Viewport,
|
||||
};
|
||||
use crate::wit_host::{
|
||||
bindings, hover_bindings, indexing_bindings, providers_bindings, semantic_bindings, services_bindings,
|
||||
syntax_bindings, views_bindings,
|
||||
bindings, commit_checks_bindings, details_bindings, file_actions_bindings, hover_bindings, indexing_bindings,
|
||||
inlay_bindings, providers_bindings, semantic_bindings, syntax_bindings, views_bindings,
|
||||
};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::ipc_path::to_ipc;
|
||||
@@ -33,12 +34,15 @@ use wasmtime_wasi::{WasiCtx, WasiCtxView, WasiView};
|
||||
|
||||
use bindings::openidea::plugin::events as wit_events;
|
||||
use bindings::openidea::plugin::types as wit_types;
|
||||
use commit_checks_bindings::exports::openidea::plugin::commit_checker as wit_cc;
|
||||
use details_bindings::exports::openidea::plugin::detail_provider as wit_detail;
|
||||
use details_bindings::openidea::plugin::detail_types as wit_dt;
|
||||
use file_actions_bindings::exports::openidea::plugin::file_actions as wit_fa;
|
||||
use hover_bindings::exports::openidea::plugin::hover_provider as wit_hover;
|
||||
use indexing_bindings::exports::openidea::plugin::document_indexer as wit_index;
|
||||
use inlay_bindings::exports::openidea::plugin::inlay_hints_provider as wit_inlay;
|
||||
use providers_bindings::exports::openidea::plugin::providers as wit_prov;
|
||||
use semantic_bindings::exports::openidea::plugin::semantic_tokens_provider as wit_sem;
|
||||
use services_bindings::exports::openidea::plugin::service_provider as wit_svc;
|
||||
use services_bindings::openidea::plugin::service_types as wit_st;
|
||||
use syntax_bindings::exports::openidea::plugin::syntax_provider as wit_syntax;
|
||||
use views_bindings::exports::openidea::plugin::view_provider as wit_view;
|
||||
use views_bindings::openidea::plugin::view_types as wit_vt;
|
||||
@@ -115,6 +119,22 @@ pub trait HostApi: Send + Sync {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
fn vcs_fetch(&self, root: &Path, cancel: &CancellationToken) -> Result<(), HostError>;
|
||||
/// The commit being prepared in `client`'s window (`None`: no repository),
|
||||
/// waiting for the window until `cancel` fires.
|
||||
fn pending_commit(
|
||||
&self,
|
||||
client: Option<&str>,
|
||||
cancel: &CancellationToken,
|
||||
) -> Result<Option<CommitDraft>, HostError> {
|
||||
let _ = (client, cancel);
|
||||
Ok(None)
|
||||
}
|
||||
/// Replaces the commit message of `client`'s window if its draft is still at
|
||||
/// `revision` (else [`HostError::Stale`]).
|
||||
fn set_commit_message(&self, client: Option<&str>, revision: u64, message: String) -> Result<(), HostError> {
|
||||
let _ = (client, revision, message);
|
||||
Err(HostError::Invalid("no commit is being prepared".into()))
|
||||
}
|
||||
/// Subscribes to (or unsubscribes from) a kind of workbench event.
|
||||
/// Project files matching `glob` (root-relative), at most `limit`.
|
||||
fn find_files(&self, root: &Path, glob: &str, limit: usize) -> Result<Vec<String>, HostError>;
|
||||
@@ -154,6 +174,8 @@ pub trait HostApi: Send + Sync {
|
||||
cwd: Option<&Path>,
|
||||
) -> Result<(), HostError>;
|
||||
fn open_location(&self, client: Option<&str>, path: &Path, line: u32, column: u32) -> Result<(), HostError>;
|
||||
/// The IDE's UI language as a BCP 47 tag.
|
||||
fn locale(&self) -> String;
|
||||
/// A setting of the plugin as JSON (project over global over the schema default).
|
||||
fn setting(&self, root: Option<&Path>, key: &str) -> Option<String>;
|
||||
}
|
||||
@@ -522,10 +544,13 @@ struct Exports {
|
||||
syntax: Option<wit_syntax::Guest>,
|
||||
providers: Option<wit_prov::Guest>,
|
||||
indexer: Option<wit_index::Guest>,
|
||||
file_actions: Option<wit_fa::Guest>,
|
||||
views: Option<wit_view::Guest>,
|
||||
semantic: Option<wit_sem::Guest>,
|
||||
services: Option<wit_svc::Guest>,
|
||||
details: Option<wit_detail::Guest>,
|
||||
hover: Option<wit_hover::Guest>,
|
||||
commit_checks: Option<wit_cc::Guest>,
|
||||
inlay: Option<wit_inlay::Guest>,
|
||||
}
|
||||
|
||||
/// Most results one syntax call may return (more are dropped).
|
||||
@@ -603,11 +628,14 @@ impl Runtime {
|
||||
let plugin_indices = bindings::PluginIndices::new(&pre).map_err(api_hint)?;
|
||||
let syntax_indices = wit_syntax::GuestIndices::new(&pre).ok();
|
||||
let index_indices = wit_index::GuestIndices::new(&pre).ok();
|
||||
let file_action_indices = wit_fa::GuestIndices::new(&pre).ok();
|
||||
let provider_indices = wit_prov::GuestIndices::new(&pre).ok();
|
||||
let view_indices = wit_view::GuestIndices::new(&pre).ok();
|
||||
let semantic_indices = wit_sem::GuestIndices::new(&pre).ok();
|
||||
let service_indices = wit_svc::GuestIndices::new(&pre).ok();
|
||||
let detail_indices = wit_detail::GuestIndices::new(&pre).ok();
|
||||
let hover_indices = wit_hover::GuestIndices::new(&pre).ok();
|
||||
let commit_check_indices = wit_cc::GuestIndices::new(&pre).ok();
|
||||
let inlay_indices = wit_inlay::GuestIndices::new(&pre).ok();
|
||||
let instance = pre.instantiate(&mut store).map_err(api_hint)?;
|
||||
let plugin = plugin_indices.load(&mut store, &instance).map_err(api_hint)?;
|
||||
let syntax = match syntax_indices {
|
||||
@@ -622,6 +650,10 @@ impl Runtime {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let file_actions = match file_action_indices {
|
||||
Some(i) => Some(i.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let views = match view_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
@@ -630,7 +662,7 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let services = match service_indices {
|
||||
let details = match detail_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
@@ -638,9 +670,29 @@ impl Runtime {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let commit_checks = match commit_check_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
let inlay = match inlay_indices {
|
||||
Some(indices) => Some(indices.load(&mut store, &instance).map_err(api_hint)?),
|
||||
None => None,
|
||||
};
|
||||
Ok(Self {
|
||||
store,
|
||||
exports: Exports { plugin, syntax, providers, views, indexer, semantic, services, hover },
|
||||
exports: Exports {
|
||||
plugin,
|
||||
syntax,
|
||||
providers,
|
||||
views,
|
||||
indexer,
|
||||
semantic,
|
||||
details,
|
||||
hover,
|
||||
commit_checks,
|
||||
inlay,
|
||||
file_actions,
|
||||
},
|
||||
limits: setup.limits,
|
||||
version: setup.version,
|
||||
})
|
||||
@@ -822,9 +874,23 @@ impl Runtime {
|
||||
selection: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<CodeAction>, CallError> {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
Ok(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit).collect())
|
||||
let mut out = Vec::new();
|
||||
if self.exports.providers.is_some() {
|
||||
let d = doc_to_wit(doc);
|
||||
let actions = self.provider_call(timeout, |p, s| p.call_code_actions(s, &d, range_to_wit(selection)))?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(code_action_from_wit));
|
||||
}
|
||||
if self.exports.file_actions.is_some() {
|
||||
let d = file_action_doc(doc);
|
||||
let actions = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_code_actions(store, &d, range_to_wit(selection))
|
||||
})?;
|
||||
out.extend(actions.into_iter().take(MAX_PROVIDER_ITEMS).map(file_action_from_wit));
|
||||
}
|
||||
if out.is_empty() && self.exports.providers.is_none() {
|
||||
return Err(CallError::missing_export("providers"));
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The edits of an action `provide_code_actions` returned with `data`.
|
||||
@@ -834,6 +900,17 @@ impl Runtime {
|
||||
action: &CodeAction,
|
||||
timeout: Duration,
|
||||
) -> Result<CodeAction, CallError> {
|
||||
if action.file_action {
|
||||
if self.exports.file_actions.is_none() {
|
||||
return Err(CallError::missing_export("file-actions"));
|
||||
}
|
||||
let d = file_action_doc(doc);
|
||||
let a = file_action_to_wit(action);
|
||||
let resolved = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.file_actions.as_ref().expect("checked").call_resolve(store, &d, &a)
|
||||
})?;
|
||||
return resolved.map(file_action_from_wit).map_err(|e| CallError::Guest(e.into()));
|
||||
}
|
||||
let d = doc_to_wit(doc);
|
||||
let a = code_action_to_wit(action);
|
||||
let resolved = self.provider_call(timeout, |p, s| p.call_resolve_code_action(s, &d, &a))?;
|
||||
@@ -921,6 +998,40 @@ impl Runtime {
|
||||
.map(|h| PluginHover { markdown: clip(h.markdown, MAX_HOVER_BYTES), range: h.range.map(range_from_wit) }))
|
||||
}
|
||||
|
||||
pub fn provide_inlay_hints(
|
||||
&mut self,
|
||||
doc: &ProviderDocument,
|
||||
range: ide_core::Range,
|
||||
timeout: Duration,
|
||||
) -> Result<Vec<PluginInlayHint>, CallError> {
|
||||
if self.exports.inlay.is_none() {
|
||||
return Err(CallError::missing_export("inlay-hints-provider"));
|
||||
}
|
||||
let d = wit_inlay::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
};
|
||||
let r = wit_types::Range { start: pos_to_wit(range.start), end: pos_to_wit(range.end) };
|
||||
let hints = self.call(Invocation::default(), timeout, |e, store| {
|
||||
e.inlay.as_ref().expect("checked").call_inlay_hints(store, &d, r)
|
||||
})?;
|
||||
Ok(hints
|
||||
.into_iter()
|
||||
.filter(|h| !h.label.trim().is_empty())
|
||||
.take(MAX_PROVIDER_ITEMS)
|
||||
.map(|h| PluginInlayHint {
|
||||
position: ide_core::Pos::new(h.position.line, h.position.character),
|
||||
label: clip(h.label, 256),
|
||||
kind: clip(h.kind, 32),
|
||||
tooltip: h.tooltip.map(|t| clip(t, 1024)),
|
||||
padding_left: h.padding_left,
|
||||
padding_right: h.padding_right,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn activate(&mut self) -> Result<(), CallError> {
|
||||
let data = self.store.data();
|
||||
let ctx = wit_types::Context {
|
||||
@@ -993,13 +1104,13 @@ impl Runtime {
|
||||
key: &str,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<ServiceTab>, CallError> {
|
||||
if self.exports.services.is_none() {
|
||||
return Err(CallError::missing_export("service-provider"));
|
||||
if self.exports.details.is_none() {
|
||||
return Err(CallError::missing_export("detail-provider"));
|
||||
}
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let services = e.services.as_ref().expect("checked");
|
||||
let r = services.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
let details = e.details.as_ref().expect("checked");
|
||||
let r = details.call_detail(&mut *store, view, key, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
@@ -1009,6 +1120,30 @@ impl Runtime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs the plugin's before-commit check `check` on `draft`.
|
||||
pub fn commit_check(
|
||||
&mut self,
|
||||
check: &str,
|
||||
draft: CommitDraft,
|
||||
invocation: Invocation,
|
||||
) -> Result<Vec<CommitProblem>, CallError> {
|
||||
if self.exports.commit_checks.is_none() {
|
||||
return Err(CallError::missing_export("commit-checker"));
|
||||
}
|
||||
let draft = crate::wit_host::commit_draft_to_wit(draft);
|
||||
let result = self.call(invocation, self.limits.timeout, |e, store| {
|
||||
let ctx = store.data_mut().new_invocation()?;
|
||||
let checks = e.commit_checks.as_ref().expect("checked");
|
||||
let r = checks.call_check(&mut *store, check, &draft, wasmtime::component::Resource::new_borrow(ctx.rep()));
|
||||
store.data_mut().drop_invocation(ctx);
|
||||
r
|
||||
})?;
|
||||
match result {
|
||||
Ok(problems) => Ok(problems.into_iter().take(MAX_COMMIT_PROBLEMS).map(commit_problem_from_wit).collect()),
|
||||
Err(e) => Err(CallError::Guest(e.into())),
|
||||
}
|
||||
}
|
||||
|
||||
/// A user action in a view or custom editor.
|
||||
pub fn view_event(&mut self, view: &str, event: ViewEvent, invocation: Invocation) -> Result<(), CallError> {
|
||||
if self.exports.views.is_none() {
|
||||
@@ -1179,7 +1314,10 @@ fn code_action_from_wit(a: wit_prov::CodeAction) -> CodeAction {
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1203,20 +1341,106 @@ fn code_action_to_wit(a: &CodeAction) -> wit_prov::CodeAction {
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_FILE_OPERATIONS: usize = 1000;
|
||||
|
||||
fn file_action_doc(doc: &ProviderDocument) -> wit_fa::Document {
|
||||
wit_fa::Document {
|
||||
path: to_ipc(&doc.path),
|
||||
language: doc.language.clone(),
|
||||
version: doc.version,
|
||||
text: doc.text.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_from_wit(a: wit_fa::FileAction) -> CodeAction {
|
||||
CodeAction {
|
||||
title: clip(a.title, MAX_TITLE),
|
||||
kind: a.kind.map(|k| clip(k, 64)),
|
||||
preferred: a.preferred,
|
||||
edits: a
|
||||
.edits
|
||||
.into_iter()
|
||||
.map(|f| (std::path::PathBuf::from(f.path), f.edits.into_iter().map(edit_from_wit).collect()))
|
||||
.collect(),
|
||||
operations: a.operations.into_iter().take(MAX_FILE_OPERATIONS).map(operation_from_wit).collect(),
|
||||
open: a.open.map(std::path::PathBuf::from),
|
||||
data: a.data.map(|d| clip(d, 64 << 10)),
|
||||
file_action: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn file_action_to_wit(a: &CodeAction) -> wit_fa::FileAction {
|
||||
wit_fa::FileAction {
|
||||
title: a.title.clone(),
|
||||
kind: a.kind.clone(),
|
||||
preferred: a.preferred,
|
||||
operations: a
|
||||
.operations
|
||||
.iter()
|
||||
.map(|op| match op {
|
||||
FileOperation::Create { path, text, overwrite } => wit_fa::FileOperation::Create(wit_fa::CreateFile {
|
||||
path: to_ipc(path),
|
||||
text: text.clone(),
|
||||
overwrite: *overwrite,
|
||||
}),
|
||||
FileOperation::Rename { from, to } => {
|
||||
wit_fa::FileOperation::Rename(wit_fa::RenameFile { path: to_ipc(from), new_path: to_ipc(to) })
|
||||
}
|
||||
FileOperation::Delete { path } => {
|
||||
wit_fa::FileOperation::Delete(wit_fa::DeleteFile { path: to_ipc(path) })
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
edits: a
|
||||
.edits
|
||||
.iter()
|
||||
.map(|(path, edits)| wit_fa::FileEdit {
|
||||
path: to_ipc(path),
|
||||
edits: edits
|
||||
.iter()
|
||||
.map(|e| wit_types::TextEdit { range: range_to_wit(e.range), text: e.new_text.clone() })
|
||||
.collect(),
|
||||
})
|
||||
.collect(),
|
||||
open: a.open.as_deref().map(to_ipc),
|
||||
data: a.data.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn operation_from_wit(op: wit_fa::FileOperation) -> FileOperation {
|
||||
match op {
|
||||
wit_fa::FileOperation::Create(c) => {
|
||||
FileOperation::Create { path: c.path.into(), text: c.text, overwrite: c.overwrite }
|
||||
}
|
||||
wit_fa::FileOperation::Rename(r) => FileOperation::Rename { from: r.path.into(), to: r.new_path.into() },
|
||||
wit_fa::FileOperation::Delete(d) => FileOperation::Delete { path: d.path.into() },
|
||||
}
|
||||
}
|
||||
|
||||
fn opt_clip(s: Option<String>, max: usize) -> Option<String> {
|
||||
s.map(|s| clip(s, max))
|
||||
}
|
||||
|
||||
const MAX_COMMIT_PROBLEMS: usize = 50;
|
||||
|
||||
fn commit_problem_from_wit(p: wit_cc::CommitProblem) -> CommitProblem {
|
||||
let (path, range) = match p.location {
|
||||
Some(l) => (Some(clip(l.path, 4096)), Some(range_from_wit(l.range))),
|
||||
None => (None, None),
|
||||
};
|
||||
CommitProblem { message: clip(p.message, 4096), path, range }
|
||||
}
|
||||
|
||||
const MAX_SERVICE_TABS: usize = 8;
|
||||
const MAX_LOG_TOGGLES: usize = 8;
|
||||
const MAX_LOG_ARGS: usize = 256;
|
||||
const MAX_LOG_ARG: usize = 4096;
|
||||
|
||||
fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
fn service_tab_from_wit(t: wit_dt::Tab) -> ServiceTab {
|
||||
let body = match t.body {
|
||||
wit_st::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_st::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_st::Pane::Log(log) => {
|
||||
wit_dt::Pane::Message(m) => ServicePane::Message(clip(m, 16 << 10)),
|
||||
wit_dt::Pane::Text(text) => ServicePane::Text(clip(text, MAX_TEXT)),
|
||||
wit_dt::Pane::Log(log) => {
|
||||
let args_fit = |args: &[String]| args.len() <= MAX_LOG_ARGS && args.iter().all(|a| a.len() <= MAX_LOG_ARG);
|
||||
if args_fit(&log.args)
|
||||
&& log.toggles.len() <= MAX_LOG_TOGGLES
|
||||
@@ -1241,12 +1465,54 @@ fn service_tab_from_wit(t: wit_st::Tab) -> ServiceTab {
|
||||
ServiceTab { id: clip(t.id, 64), label: clip(t.label, MAX_TITLE), body }
|
||||
}
|
||||
|
||||
/// A `table` body carried by the `other` case (the sortable variant, plugins/
|
||||
/// README.md §10): the usual table fields as JSON plus `sort`, the column the
|
||||
/// plugin orders by and the direction, or null. Anything unparsable degrades
|
||||
/// like a body this host doesn't know.
|
||||
fn sortable_table_from_json(data: &str) -> Option<ViewTable> {
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Json {
|
||||
#[serde(default)]
|
||||
columns: Vec<String>,
|
||||
#[serde(default)]
|
||||
total_rows: u32,
|
||||
#[serde(default)]
|
||||
first_row: u32,
|
||||
#[serde(default)]
|
||||
rows: Vec<Vec<String>>,
|
||||
#[serde(default)]
|
||||
editable: bool,
|
||||
#[serde(default)]
|
||||
sort: Option<TableSort>,
|
||||
}
|
||||
let json: Json = serde_json::from_str(data).ok()?;
|
||||
Some(ViewTable {
|
||||
columns: json.columns.into_iter().take(1000).map(|c| clip(c, MAX_TITLE)).collect(),
|
||||
total_rows: json.total_rows,
|
||||
first_row: json.first_row,
|
||||
rows: json
|
||||
.rows
|
||||
.into_iter()
|
||||
.take(MAX_TABLE_ROWS as usize)
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: json.editable,
|
||||
sortable: true,
|
||||
sort: json.sort,
|
||||
})
|
||||
}
|
||||
|
||||
fn view_from_wit(v: wit_vt::View) -> View {
|
||||
let body = match v.body {
|
||||
wit_vt::Body::Message(m) => ViewBody::Message(clip(m, 16 << 10)),
|
||||
wit_vt::Body::Other(e) => {
|
||||
ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64)))
|
||||
}
|
||||
wit_vt::Body::Other(e) => match e.kind.as_str() {
|
||||
"table" => match sortable_table_from_json(&e.data) {
|
||||
Some(t) => ViewBody::Table(t),
|
||||
None => ViewBody::Message("This view needs a newer version of OpenIDEA (table).".into()),
|
||||
},
|
||||
_ => ViewBody::Message(format!("This view needs a newer version of OpenIDEA ({}).", clip(e.kind, 64))),
|
||||
},
|
||||
wit_vt::Body::Tree(items) => ViewBody::Tree(
|
||||
items
|
||||
.into_iter()
|
||||
@@ -1305,6 +1571,9 @@ fn view_from_wit(v: wit_vt::View) -> View {
|
||||
.map(|r| r.into_iter().take(1000).map(|c| clip(c, MAX_CELL)).collect())
|
||||
.collect(),
|
||||
editable: t.editable,
|
||||
// The plain body predates sortable headers; `other` kind `table` opts in.
|
||||
sortable: false,
|
||||
sort: None,
|
||||
}),
|
||||
wit_vt::Body::Html(h) => ViewBody::Html(clip(h, MAX_HTML)),
|
||||
};
|
||||
@@ -1338,6 +1607,12 @@ fn event_to_wit_view(e: ViewEvent) -> wit_vt::ViewEvent {
|
||||
ViewEvent::InsertRow { before } => W::InsertRow(before),
|
||||
ViewEvent::DeleteRows { rows } => W::DeleteRows(rows),
|
||||
ViewEvent::Message { text } => W::Message(text),
|
||||
// A sortable table's header click, as an `other` view-event (plugins/
|
||||
// README.md §10): the SDKs decode the kinds they know.
|
||||
ViewEvent::Sort { column, descending } => W::Other(wit_types::Extension {
|
||||
kind: "sort".into(),
|
||||
data: format!(r#"{{"column":{column},"descending":{descending}}}"#),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1360,6 +1635,8 @@ mod tests {
|
||||
kind: None,
|
||||
preferred: false,
|
||||
edits: vec![(path.into(), Vec::new())],
|
||||
operations: Vec::new(),
|
||||
open: None,
|
||||
data: None,
|
||||
};
|
||||
assert_eq!(code_action_to_wit(&action).edits[0].path, expected);
|
||||
@@ -1383,4 +1660,41 @@ mod tests {
|
||||
let ViewBody::Form(fields) = form.body else { panic!("a form") };
|
||||
assert_eq!(fields.iter().map(|f| f.key.as_str()).collect::<Vec<_>>(), ["name"], "unknown fields are left out");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sortable_table_arrives_through_other() {
|
||||
let view = |data: &str| wit_vt::View {
|
||||
toolbar: Vec::new(),
|
||||
body: wit_vt::Body::Other(wit_types::Extension { kind: "table".into(), data: data.into() }),
|
||||
status: None,
|
||||
};
|
||||
let shown = view_from_wit(view(
|
||||
r#"{"columns":["ext","files"],"totalRows":3,"firstRow":1,"rows":[["rs","2"],["md","1"]],
|
||||
"editable":false,"sort":{"column":1,"descending":true}}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = shown.body else { panic!("{shown:?}") };
|
||||
assert_eq!(t.columns, ["ext".to_owned(), "files".into()]);
|
||||
assert_eq!((t.total_rows, t.first_row, t.editable), (3, 1, false));
|
||||
assert!(t.sortable, "a `table` through `other` opts the headers in");
|
||||
assert_eq!(t.sort, Some(TableSort { column: 1, descending: true }));
|
||||
|
||||
// No sort yet: still clickable headers, just no arrow.
|
||||
let fresh = view_from_wit(view(
|
||||
r#"{"columns":["a"],"totalRows":0,"firstRow":0,"rows":[],"editable":false,"sort":null}"#,
|
||||
));
|
||||
let ViewBody::Table(t) = fresh.body else { panic!("{fresh:?}") };
|
||||
assert!(t.sortable);
|
||||
assert_eq!(t.sort, None);
|
||||
|
||||
// Broken JSON degrades like any body the host doesn't know.
|
||||
let broken = view_from_wit(view("{not json"));
|
||||
assert!(matches!(broken.body, ViewBody::Message(_)), "{broken:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sort_click_leaves_as_an_other_extension() {
|
||||
let wit = event_to_wit_view(ViewEvent::Sort { column: 2, descending: true });
|
||||
let wit_vt::ViewEvent::Other(e) = wit else { panic!("{wit:?}") };
|
||||
assert_eq!((e.kind.as_str(), e.data.as_str()), ("sort", r#"{"column":2,"descending":true}"#));
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,53 @@ use std::path::{Path, PathBuf};
|
||||
/// Largest file `read-file` returns.
|
||||
pub const MAX_READ_BYTES: u64 = 16 << 20;
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
/// The canonical `root` and the absolute form of `path` (absolute, or
|
||||
/// relative to `root`) — before any symlink or existence check.
|
||||
fn joined_path(root: &Path, path: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok((root, joined))
|
||||
}
|
||||
|
||||
/// `joined` after `..`/symlink resolution as far as the file system allows:
|
||||
/// the canonical path when it exists, else the canonicalized deepest existing
|
||||
/// ancestor with the still-missing tail resolved textually — so the location
|
||||
/// of a missing path is still judged where it would really land.
|
||||
fn resolve_real(joined: &Path) -> PathBuf {
|
||||
let mut tail = Vec::new();
|
||||
let mut candidate = joined.to_path_buf();
|
||||
let mut real = loop {
|
||||
match candidate.canonicalize() {
|
||||
Ok(real) => break real,
|
||||
Err(_) => {
|
||||
let Some(last) = candidate.components().next_back() else {
|
||||
return joined.to_path_buf();
|
||||
};
|
||||
tail.push(last.as_os_str().to_owned());
|
||||
if !candidate.pop() {
|
||||
return joined.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
for part in tail.iter().rev() {
|
||||
if part == ".." {
|
||||
real.pop();
|
||||
} else if part != "." {
|
||||
real.push(part);
|
||||
}
|
||||
}
|
||||
real
|
||||
}
|
||||
|
||||
/// Resolves `path` (absolute, or relative to `root`) and checks that the real
|
||||
/// path — after following `..` and symlinks — stays inside `root`.
|
||||
pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
let real = joined.canonicalize().map_err(|_| format!("{path}: no such file"))?;
|
||||
if !real.starts_with(&root) {
|
||||
return Err(format!("{path}: access outside the project root is not allowed"));
|
||||
@@ -21,6 +59,58 @@ pub fn resolve_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Whether `path` (absolute, or relative to `root`) lands inside `root`,
|
||||
/// without requiring it to exist: `Ok(true)` also for a missing path, so
|
||||
/// callers distinguish "outside" from "absent". Existing symlinks are
|
||||
/// followed; a missing tail is judged at its would-be location.
|
||||
pub fn inside_root(root: &Path, path: &str) -> Result<bool, String> {
|
||||
let (root, joined) = joined_path(root, path)?;
|
||||
Ok(resolve_real(&joined).starts_with(&root))
|
||||
}
|
||||
|
||||
/// Like [`resolve_in_root`] for a path that may not exist yet (a file about to
|
||||
/// be created) or that names a symlink itself (to delete or rename it): the
|
||||
/// directory part is resolved through symlinks, the final name is kept as is,
|
||||
/// and the result must stay inside `root`.
|
||||
pub fn resolve_new_in_root(root: &Path, path: &str) -> Result<PathBuf, String> {
|
||||
let outside = || format!("{path}: access outside the project root is not allowed");
|
||||
if path.is_empty() {
|
||||
return Err("empty path".into());
|
||||
}
|
||||
let root = root.canonicalize().map_err(|e| format!("project root unavailable: {e}"))?;
|
||||
let requested = Path::new(path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
let mut lexical = PathBuf::new();
|
||||
for part in joined.components() {
|
||||
match part {
|
||||
std::path::Component::ParentDir => {
|
||||
lexical.pop();
|
||||
}
|
||||
std::path::Component::CurDir => {}
|
||||
part => lexical.push(part),
|
||||
}
|
||||
}
|
||||
let (Some(name), Some(parent)) = (lexical.file_name(), lexical.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
let mut missing = Vec::new();
|
||||
let mut existing = parent;
|
||||
while !existing.exists() {
|
||||
let (Some(dir), Some(above)) = (existing.file_name(), existing.parent()) else {
|
||||
return Err(outside());
|
||||
};
|
||||
missing.push(dir);
|
||||
existing = above;
|
||||
}
|
||||
let mut real = existing.canonicalize().map_err(|e| format!("{path}: {e}"))?;
|
||||
real.extend(missing.into_iter().rev());
|
||||
real.push(name);
|
||||
if !real.starts_with(&root) {
|
||||
return Err(outside());
|
||||
}
|
||||
Ok(real)
|
||||
}
|
||||
|
||||
/// Reads a UTF-8 file that `resolve_in_root` already approved.
|
||||
pub fn read_text(real: &Path) -> Result<String, String> {
|
||||
let meta = std::fs::metadata(real).map_err(|e| e.to_string())?;
|
||||
@@ -62,6 +152,57 @@ mod tests {
|
||||
assert!(read_text(&resolve_in_root(&root, "src").unwrap()).unwrap_err().contains("not a file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_root_covers_missing_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
std::fs::write(root.join("src/a.txt"), "hi").unwrap();
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
|
||||
assert!(inside_root(&root, "src/a.txt").unwrap());
|
||||
assert!(inside_root(&root, "src").unwrap());
|
||||
assert!(inside_root(&root, "missing.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/deep/nested.txt").unwrap());
|
||||
assert!(inside_root(&root, "missing/../src/a.txt").unwrap());
|
||||
assert!(!inside_root(&root, "../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/../../secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "missing/sub/../../../secret.txt").unwrap());
|
||||
let abs_out = tmp.path().join("secret.txt");
|
||||
assert!(!inside_root(&root, abs_out.to_str().unwrap()).unwrap());
|
||||
assert!(inside_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_paths_are_confined_to_root() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(root.join("src")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "src/new/a.txt").unwrap(), root_real.join("src/new/a.txt"));
|
||||
assert_eq!(resolve_new_in_root(&root, "src/../b.txt").unwrap(), root_real.join("b.txt"));
|
||||
assert!(resolve_new_in_root(&root, "../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "src/../../x.txt").unwrap_err().contains("outside"));
|
||||
assert!(resolve_new_in_root(&root, "/etc/new.txt").is_err());
|
||||
assert!(resolve_new_in_root(&root, "").is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_symlink_is_named_not_followed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::write(root.join("target.txt"), "t").unwrap();
|
||||
std::os::unix::fs::symlink(root.join("target.txt"), root.join("link.txt")).unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("out")).unwrap();
|
||||
let root_real = root.canonicalize().unwrap();
|
||||
|
||||
assert_eq!(resolve_new_in_root(&root, "link.txt").unwrap(), root_real.join("link.txt"));
|
||||
assert!(resolve_new_in_root(&root, "out/x.txt").unwrap_err().contains("outside"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlinks_cannot_escape() {
|
||||
@@ -71,6 +212,11 @@ mod tests {
|
||||
std::fs::write(tmp.path().join("secret.txt"), "s").unwrap();
|
||||
std::os::unix::fs::symlink(tmp.path().join("secret.txt"), root.join("link")).unwrap();
|
||||
assert!(resolve_in_root(&root, "link").unwrap_err().contains("outside"));
|
||||
assert!(!inside_root(&root, "link").unwrap());
|
||||
assert!(!inside_root(&root, "link/../x").unwrap());
|
||||
std::os::unix::fs::symlink(tmp.path(), root.join("dirlink")).unwrap();
|
||||
assert!(!inside_root(&root, "dirlink/secret.txt").unwrap());
|
||||
assert!(!inside_root(&root, "dirlink/missing.txt").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -77,14 +77,15 @@ pub struct PickItem {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`, `ui.confirm`).
|
||||
/// What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
|
||||
/// `ui.input-multiline`, `ui.confirm`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum Prompt {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
QuickPick { title: String, items: Vec<PickItem>, multiple: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Input { prompt: String, value: Option<String> },
|
||||
Input { prompt: String, value: Option<String>, multiline: bool },
|
||||
#[serde(rename_all = "camelCase")]
|
||||
Confirm { title: String, message: String },
|
||||
}
|
||||
@@ -210,6 +211,40 @@ pub struct RepositoryStatus {
|
||||
pub changed_files: u32,
|
||||
}
|
||||
|
||||
/// A file a pending commit changes (WIT `commit.commit-change`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitChange {
|
||||
pub repository: String,
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: String,
|
||||
/// `git diff` of the included changes; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// What Commit would commit (WIT `commit.commit-draft`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitDraft {
|
||||
pub revision: u64,
|
||||
pub message: String,
|
||||
pub amend: bool,
|
||||
pub changes: Vec<CommitChange>,
|
||||
}
|
||||
|
||||
/// A problem a plugin's before-commit check reported (WIT `commit-checker.commit-problem`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CommitProblem {
|
||||
pub message: String,
|
||||
/// Absolute path of the file it is in, if any (IPC form).
|
||||
pub path: Option<String>,
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
/// Resource limits and supervision policy for plugin code.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Limits {
|
||||
@@ -407,8 +442,21 @@ pub struct CodeAction {
|
||||
pub kind: Option<String>,
|
||||
pub preferred: bool,
|
||||
pub edits: Vec<(PathBuf, Vec<ide_core::TextEdit>)>,
|
||||
/// Applied before `edits`, in one workspace edit with them.
|
||||
pub operations: Vec<FileOperation>,
|
||||
/// Opened once the action is applied.
|
||||
pub open: Option<PathBuf>,
|
||||
/// The plugin's reference: the edits come from `resolve_code_action`.
|
||||
pub data: Option<String>,
|
||||
/// Listed by the `file-actions` export, which also resolves it.
|
||||
pub file_action: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum FileOperation {
|
||||
Create { path: PathBuf, text: String, overwrite: bool },
|
||||
Rename { from: PathBuf, to: PathBuf },
|
||||
Delete { path: PathBuf },
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
@@ -432,6 +480,16 @@ pub struct PluginHover {
|
||||
pub range: Option<ide_core::Range>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PluginInlayHint {
|
||||
pub position: ide_core::Pos,
|
||||
pub label: String,
|
||||
pub kind: String,
|
||||
pub tooltip: Option<String>,
|
||||
pub padding_left: bool,
|
||||
pub padding_right: bool,
|
||||
}
|
||||
|
||||
// ---- declarative views (wit/views.wit) ---------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -481,6 +539,14 @@ pub struct ViewField {
|
||||
pub value: FieldValue,
|
||||
}
|
||||
|
||||
/// A sortable table's order: the column and the direction.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TableSort {
|
||||
pub column: u32,
|
||||
pub descending: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ViewTable {
|
||||
@@ -489,6 +555,10 @@ pub struct ViewTable {
|
||||
pub first_row: u32,
|
||||
pub rows: Vec<Vec<String>>,
|
||||
pub editable: bool,
|
||||
/// The plugin opted its headers in to click-to-sort (a `table` body through `other`).
|
||||
pub sortable: bool,
|
||||
/// The sort the plugin orders by (none: no arrow yet).
|
||||
pub sort: Option<TableSort>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -511,7 +581,7 @@ pub struct View {
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// A tab of the Services pane of a plugin's node (`service-provider.detail`).
|
||||
/// A tab of the Services pane of a plugin's node (`detail-provider.detail`).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServiceTab {
|
||||
@@ -569,4 +639,7 @@ pub enum ViewEvent {
|
||||
InsertRow { before: u32 },
|
||||
DeleteRows { rows: Vec<u32> },
|
||||
Message { text: String },
|
||||
// A sortable table's header click: the column and the direction (leaves
|
||||
// as an `other` view-event, kind `sort`). See `TableSort`.
|
||||
Sort { column: u32, descending: bool },
|
||||
}
|
||||
@@ -42,6 +42,7 @@ pub(crate) mod syntax_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -69,6 +70,7 @@ pub(crate) mod providers_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -96,6 +98,7 @@ pub(crate) mod views_bindings {
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/vcs": super::bindings::openidea::plugin::vcs,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
"openidea:plugin/events": super::bindings::openidea::plugin::events,
|
||||
"openidea:plugin/tasks": super::bindings::openidea::plugin::tasks,
|
||||
"openidea:plugin/diagnostics": super::bindings::openidea::plugin::diagnostics,
|
||||
@@ -109,8 +112,8 @@ pub(crate) mod views_bindings {
|
||||
}
|
||||
|
||||
use bindings::openidea::plugin::{
|
||||
diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks, types as wit, ui,
|
||||
vcs, views, workspace,
|
||||
commit, diagnostics, editor, events, invocation, log, net, process, secrets, settings, storage, tasks,
|
||||
types as wit, ui, vcs, views, workspace,
|
||||
};
|
||||
|
||||
/// A project handle: its root.
|
||||
@@ -352,7 +355,14 @@ impl ui::Host for StoreData {
|
||||
}
|
||||
|
||||
fn input(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt = Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)) };
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: false };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
fn input_multiline(&mut self, prompt: String, value: Option<String>) -> Fallible<Option<String>> {
|
||||
let prompt =
|
||||
Prompt::Input { prompt: clip(prompt, MAX_TITLE), value: value.map(|v| clip(v, 4096)), multiline: true };
|
||||
Ok(self.ask(prompt).map(|answer| answer.and_then(|text| text.into_iter().next())))
|
||||
}
|
||||
|
||||
@@ -360,6 +370,10 @@ impl ui::Host for StoreData {
|
||||
let prompt = Prompt::Confirm { title: clip(title, MAX_TITLE), message: clip(body, 16 << 10) };
|
||||
Ok(self.ask(prompt).map(|answer| answer.is_some()))
|
||||
}
|
||||
|
||||
fn locale(&mut self) -> wasmtime::Result<String> {
|
||||
Ok(self.host.locale())
|
||||
}
|
||||
}
|
||||
|
||||
impl storage::Host for StoreData {
|
||||
@@ -421,7 +435,10 @@ impl workspace::HostProject for StoreData {
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => return Ok(Err(wit::Error::Invalid(e))),
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
if let Some(text) = self.host.buffer_text(&real) {
|
||||
return Ok(Ok(text));
|
||||
@@ -429,6 +446,57 @@ impl workspace::HostProject for StoreData {
|
||||
Ok(sandbox::read_text(&real).map_err(wit::Error::Failed))
|
||||
}
|
||||
|
||||
fn exists(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<bool> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
match sandbox::inside_root(&root, &path) {
|
||||
Err(e) => Ok(Err(wit::Error::Invalid(e))),
|
||||
Ok(false) => {
|
||||
Ok(Err(wit::Error::Invalid(format!("{path}: access outside the project root is not allowed"))))
|
||||
}
|
||||
Ok(true) => {
|
||||
let requested = Path::new(&path);
|
||||
let joined = if requested.is_absolute() { requested.to_path_buf() } else { root.join(requested) };
|
||||
Ok(Ok(joined.exists()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn stat(&mut self, this: Resource<ProjectRes>, path: String) -> Fallible<workspace::FileStat> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
let root = self.table.get(&this)?.root.clone();
|
||||
let real = match sandbox::resolve_in_root(&root, &path) {
|
||||
Ok(real) => real,
|
||||
Err(e) => {
|
||||
let e = if e.ends_with("no such file") { wit::Error::NotFound(e) } else { wit::Error::Invalid(e) };
|
||||
return Ok(Err(e));
|
||||
}
|
||||
};
|
||||
let meta = match std::fs::symlink_metadata(&real) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return Ok(Err(wit::Error::NotFound(format!("{path}: no such file"))));
|
||||
}
|
||||
Err(e) => return Ok(Err(wit::Error::Failed(e.to_string()))),
|
||||
};
|
||||
let kind = if meta.is_file() {
|
||||
workspace::FileKind::File
|
||||
} else if meta.is_dir() {
|
||||
workspace::FileKind::Directory
|
||||
} else if meta.is_symlink() {
|
||||
workspace::FileKind::Symlink
|
||||
} else {
|
||||
workspace::FileKind::Other
|
||||
};
|
||||
let modified =
|
||||
meta.modified().ok().and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()).map(|d| d.as_secs());
|
||||
Ok(Ok(workspace::FileStat { kind, size: meta.len(), modified }))
|
||||
}
|
||||
|
||||
fn find_files(&mut self, this: Resource<ProjectRes>, glob: String, limit: u32) -> Fallible<Vec<String>> {
|
||||
if let Err(e) = self.require(Capability::ProjectRead) {
|
||||
return Ok(Err(e));
|
||||
@@ -571,6 +639,62 @@ impl vcs::Host for StoreData {
|
||||
}
|
||||
}
|
||||
|
||||
impl commit::Host for StoreData {
|
||||
fn pending_commit(&mut self) -> Fallible<Option<commit::CommitDraft>> {
|
||||
if let Err(e) = self.require(Capability::VcsRead) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
// Waiting for the window (saving, diffing) doesn't count against the call's budget.
|
||||
let client = self.invocation.client.clone();
|
||||
let started = Instant::now();
|
||||
let draft = self.host.pending_commit(client.as_deref(), &self.cancel.token());
|
||||
let waited = started.elapsed();
|
||||
self.deadline += waited;
|
||||
self.call_started += waited;
|
||||
Ok(draft.map(|d| d.map(commit_draft_to_wit)).map_err(to_wit))
|
||||
}
|
||||
|
||||
fn set_commit_message(&mut self, revision: u64, message: String) -> Fallible<()> {
|
||||
if let Err(e) = self.require(Capability::VcsWrite) {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if let Err(e) = self.require_interactive() {
|
||||
return Ok(Err(e));
|
||||
}
|
||||
if message.len() > MAX_COMMIT_MESSAGE {
|
||||
return Ok(Err(wit::Error::Invalid(format!("a commit message is at most {MAX_COMMIT_MESSAGE} bytes"))));
|
||||
}
|
||||
let client = self.invocation.client.clone();
|
||||
Ok(self.host.set_commit_message(client.as_deref(), revision, message).map_err(to_wit))
|
||||
}
|
||||
}
|
||||
|
||||
/// Longest commit message a plugin may set.
|
||||
const MAX_COMMIT_MESSAGE: usize = 64 << 10;
|
||||
|
||||
pub(crate) fn commit_draft_to_wit(d: crate::types::CommitDraft) -> commit::CommitDraft {
|
||||
commit::CommitDraft {
|
||||
revision: d.revision,
|
||||
message: d.message,
|
||||
amend: d.amend,
|
||||
changes: d
|
||||
.changes
|
||||
.into_iter()
|
||||
.map(|c| commit::CommitChange {
|
||||
repository: c.repository,
|
||||
path: c.path,
|
||||
old_path: c.old_path,
|
||||
status: c.status,
|
||||
diff: c.diff,
|
||||
truncated: c.truncated,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
impl events::Host for StoreData {
|
||||
fn subscribe(&mut self, kind: String) -> Fallible<()> {
|
||||
let Some(kind) = EventKind::parse(&kind) else {
|
||||
@@ -815,10 +939,10 @@ pub(crate) mod indexing_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `service-provider` export.
|
||||
pub(crate) mod services_bindings {
|
||||
/// The optional `detail-provider` export.
|
||||
pub(crate) mod details_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "services", path: "../../wit",
|
||||
world: "details", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
@@ -828,6 +952,20 @@ pub(crate) mod services_bindings {
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `commit-checker` export.
|
||||
pub(crate) mod commit_checks_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "commit-checks", path: "../../wit",
|
||||
with: {
|
||||
"openidea:plugin/types": super::bindings::openidea::plugin::types,
|
||||
"openidea:plugin/workspace": super::bindings::openidea::plugin::workspace,
|
||||
"openidea:plugin/editor": super::bindings::openidea::plugin::editor,
|
||||
"openidea:plugin/invocation": super::bindings::openidea::plugin::invocation,
|
||||
"openidea:plugin/commit": super::bindings::openidea::plugin::commit,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod semantic_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "semantic-tokens", path: "../../wit",
|
||||
@@ -841,3 +979,18 @@ pub(crate) mod hover_bindings {
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) mod inlay_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "inlay-hints", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
|
||||
/// The optional `file-actions` export.
|
||||
pub(crate) mod file_actions_bindings {
|
||||
wasmtime::component::bindgen!({
|
||||
world: "file-actions-provider", path: "../../wit",
|
||||
with: { "openidea:plugin/types": super::bindings::openidea::plugin::types },
|
||||
});
|
||||
}
|
||||
@@ -30,6 +30,7 @@ fn components() -> Vec<PathBuf> {
|
||||
repo().join("crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm"),
|
||||
repo().join("crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm"),
|
||||
];
|
||||
let official = repo().join("plugins/official");
|
||||
let mut dirs: Vec<_> = std::fs::read_dir(&official).unwrap().map(|e| e.unwrap().path()).collect();
|
||||
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-commit"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,9 @@
|
||||
# Commit test fixture
|
||||
|
||||
`Describe Changes` (in the `commit.message` toolbar) reads the commit being
|
||||
prepared and writes a message listing its files; the `markers` before-commit
|
||||
check reports a message without a `type: ` prefix and every `DO NOT COMMIT`
|
||||
added by the commit.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_commit` to exercise the `commit`
|
||||
interface and the optional `commit-checker` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5752ebfddd9d2f2aef2ed7dfea7f39b3439f28b7f9bd09e2f32e40a65fc65f07
|
||||
@@ -0,0 +1,21 @@
|
||||
id = "test.commit"
|
||||
name = "Commit fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-command:*"]
|
||||
|
||||
[capabilities]
|
||||
required = ["vcs.read", "vcs.write"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "describe"
|
||||
title = "Commit Fixture: Describe Changes"
|
||||
|
||||
[[contributes.menus]]
|
||||
menu = "commit.message"
|
||||
command = "describe"
|
||||
|
||||
[[contributes.commitChecks]]
|
||||
id = "markers"
|
||||
title = "Commit Markers"
|
||||
@@ -0,0 +1,57 @@
|
||||
use openidea_plugin::commit::{CommitChecks, CommitProblem};
|
||||
use openidea_plugin::{CommitDraft, Error, Invocation, Location, Plugin, Position, Range, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
if id != "describe" {
|
||||
return Err(Error::NotFound(id.into()));
|
||||
}
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let names: Vec<String> = draft
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| format!("{} {}", c.status, c.path.rsplit('/').next().unwrap_or(&c.path)))
|
||||
.collect();
|
||||
let message = format!("chore: {}", names.join(", "));
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
Ok(Some(message))
|
||||
}
|
||||
}
|
||||
|
||||
impl CommitChecks for Fixture {
|
||||
fn check(&mut self, check: &str, draft: &CommitDraft, _: &Invocation) -> Result<Vec<CommitProblem>, Error> {
|
||||
if check != "markers" {
|
||||
return Err(Error::NotFound(check.into()));
|
||||
}
|
||||
let mut problems = Vec::new();
|
||||
if !draft.message.split_once(": ").is_some_and(|(t, _)| !t.is_empty() && !t.contains(' ')) {
|
||||
problems.push(CommitProblem::new("The message does not start with `type: `."));
|
||||
}
|
||||
for change in &draft.changes {
|
||||
let Some(diff) = &change.diff else { continue };
|
||||
let mut line = 0u32;
|
||||
for l in diff.lines() {
|
||||
if let Some(hunk) = l.strip_prefix("@@ -") {
|
||||
// `@@ -a,b +c,d @@`: c is the 1-based first line of the new side.
|
||||
let new = hunk.split(" +").nth(1).and_then(|s| s.split([',', ' ']).next());
|
||||
line = new.and_then(|n| n.parse::<u32>().ok()).unwrap_or(1).saturating_sub(1);
|
||||
} else if l.starts_with('+') && !l.starts_with("+++") {
|
||||
if l.contains("DO NOT COMMIT") {
|
||||
let at = Position::new(line, 0);
|
||||
let location = Location { path: change.path.clone(), range: Range::new(at, at) };
|
||||
problems.push(CommitProblem::new("Remove the DO NOT COMMIT marker.").at(location));
|
||||
}
|
||||
line += 1;
|
||||
} else if !l.starts_with('-') {
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(problems)
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, commit_checks);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
885badd4727639ad3205b2551d2b8406490fc05b72c684c5d17f09aadd9541bd
|
||||
5f0ec195975e34065350eddabc252b830d2d7e8c87842be2d6310bff5a22eb67
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
//! Sample OpenIDEA plugin, written with the `openidea-plugin` SDK.
|
||||
//! Build with `openidea-plugin build crates/ide-plugin/tests/fixtures/hello` (see plugins/README.md).
|
||||
|
||||
use openidea_plugin::services::{Pane, ProcessLog, Services, Tab};
|
||||
use openidea_plugin::details::{Details, Pane, ProcessLog, Tab};
|
||||
use openidea_plugin::views::{Body, Button, TreeItem, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Context, Error, Invocation, LogLevel, NotificationLevel, Plugin, host, register_plugin};
|
||||
use std::collections::BTreeSet;
|
||||
@@ -98,14 +98,14 @@ impl Views for Hello {
|
||||
}
|
||||
|
||||
/// As a services view (tests): a node's key is the `sh -c` script its Log tab runs.
|
||||
impl Services for Hello {
|
||||
impl Details for Hello {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
let log = ProcessLog::new("sh", &["-c", key]).toggle("named", "Named", &["named"]);
|
||||
Ok(vec![Tab::new("log", "Log", log), Tab::new("info", "Info", Pane::Text(format!("{view}: {key}")))])
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Hello, views, services);
|
||||
register_plugin!(Hello, views, details);
|
||||
|
||||
/// Today's UTC date as `YYYY-MM-DD` (WASI wall clock).
|
||||
fn today() -> String {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
ff1b39ac7850fbe4495e274dd78cca4c906a20705e8b9a975f58cf564e8ff137
|
||||
9551f337f0949c7249b022b8663333a66ae3534a7e0e112b37ed93d431960a54
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
c9bb4bc3603d9e7c770cf13ad413f5b3a787e78dbeed8f41a3b8537c0740275d
|
||||
600ef2642cfb359fea47e10a6a056e3e34f230681152aaff5077127da4325f10
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-inlay-hints"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,7 @@
|
||||
# Inlay hints test fixture
|
||||
|
||||
After every `->name()` call in a PHP document that declares
|
||||
`function name(): Type`, shows `: Type` as a type hint.
|
||||
|
||||
Used by `cargo test -p ide-host --test plugin_inlay_hints` to exercise the
|
||||
optional `inlay-hints-provider` export. It is not a marketplace plugin.
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
c766b42405732d8d0dfec0955c6592e937928c96f2aedcbf317cddb55fccbdcb
|
||||
@@ -0,0 +1,9 @@
|
||||
id = "test.inlay-hints"
|
||||
name = "Inlay hints fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[[contributes.providers]]
|
||||
kinds = ["inlay-hints"]
|
||||
languages = ["php"]
|
||||
@@ -0,0 +1,47 @@
|
||||
use openidea_plugin::inlay_hints::{Document, InlayHint, InlayHints, InlayKind};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, Range, register_plugin, text};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Fixture;
|
||||
|
||||
impl Plugin for Fixture {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(id.into()))
|
||||
}
|
||||
}
|
||||
|
||||
impl InlayHints for Fixture {
|
||||
fn inlay_hints(&mut self, doc: &Document, _range: Range) -> Vec<InlayHint> {
|
||||
let text = doc.text.as_str();
|
||||
let mut out = Vec::new();
|
||||
let mut rest = 0;
|
||||
while let Some(i) = text[rest..].find("->") {
|
||||
let start = rest + i + 2;
|
||||
let end = text[start..]
|
||||
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
|
||||
.map_or(text.len(), |n| start + n);
|
||||
rest = end;
|
||||
if end == start || !text[end..].starts_with("()") {
|
||||
continue;
|
||||
}
|
||||
let name = &text[start..end];
|
||||
let declared = format!("function {name}(): ");
|
||||
let Some(at) = text.find(&declared) else { continue };
|
||||
let ty: String = text[at + declared.len()..]
|
||||
.chars()
|
||||
.take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '\\' | '?'))
|
||||
.collect();
|
||||
if ty.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(
|
||||
InlayHint::new(text::position(text, end + 2), format!(": {ty}"))
|
||||
.kind(InlayKind::Type)
|
||||
.tooltip(format!("{name}() returns {ty}")),
|
||||
);
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Fixture, inlay_hints);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
a9cb7302b86e12e05cbbf87fb402609e2f88d3a463f6c6e96b254e643b76183d
|
||||
de40d28209203e58809622204dc17818a1c110f66515d3bb2dbca100861f544d
|
||||
@@ -75,6 +75,7 @@ impl Plugin for Jobs {
|
||||
Ok(Some(picked.map_or("none".to_owned(), |keys| keys.join(","))))
|
||||
}
|
||||
"input" => Ok(Some(host::input("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"input-multiline" => Ok(Some(host::input_multiline("Name", arg)?.unwrap_or_else(|| "none".to_owned()))),
|
||||
"confirm" => Ok(Some(host::confirm("Delete", "Really?")?.to_string())),
|
||||
"reveal" => host::reveal_view(arg.unwrap_or_default()).map(|()| None),
|
||||
"selection" => {
|
||||
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
0be22488fe8f20a59f48fc61089b3db791a5d723d71e4d654bfd008038c01003
|
||||
1cde3e85cce52c303c9629dade79a4349e0963fb868947a8e53f74ae248efd24
|
||||
@@ -7,6 +7,7 @@ wit_bindgen::generate!({
|
||||
world misbehave {
|
||||
include openidea:plugin/plugin-syntax@1.3.0;
|
||||
export openidea:plugin/providers@1.3.0;
|
||||
export openidea:plugin/file-actions@1.3.0;
|
||||
}
|
||||
",
|
||||
path: "../../../../../wit",
|
||||
@@ -189,4 +190,64 @@ impl providers::Guest for Misbehave {
|
||||
}
|
||||
}
|
||||
|
||||
/// `files`: one action that creates, renames and deletes files, then opens
|
||||
/// one, and one listed with `data` only, whose file `resolve` creates.
|
||||
impl exports::openidea::plugin::file_actions::Guest for Misbehave {
|
||||
fn code_actions(
|
||||
doc: exports::openidea::plugin::file_actions::Document,
|
||||
_selection: types::Range,
|
||||
) -> Vec<exports::openidea::plugin::file_actions::FileAction> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, DeleteFile, FileAction, FileOperation, RenameFile};
|
||||
if doc.text != "files" {
|
||||
return Vec::new();
|
||||
}
|
||||
let dir = doc.path.rsplit_once('/').map_or("", |(dir, _)| dir).to_owned();
|
||||
vec![
|
||||
FileAction {
|
||||
title: "Reorganise".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: vec![
|
||||
FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/created.txt"),
|
||||
text: "hello".into(),
|
||||
overwrite: false,
|
||||
}),
|
||||
FileOperation::Rename(RenameFile {
|
||||
path: format!("{dir}/old.txt"),
|
||||
new_path: format!("{dir}/renamed.txt"),
|
||||
}),
|
||||
FileOperation::Delete(DeleteFile { path: format!("{dir}/gone.txt") }),
|
||||
],
|
||||
edits: Vec::new(),
|
||||
open: Some(format!("{dir}/created.txt")),
|
||||
data: None,
|
||||
},
|
||||
FileAction {
|
||||
title: "Create later".into(),
|
||||
kind: Some("quickfix".into()),
|
||||
preferred: false,
|
||||
operations: Vec::new(),
|
||||
edits: Vec::new(),
|
||||
open: None,
|
||||
data: Some(dir),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn resolve(
|
||||
_doc: exports::openidea::plugin::file_actions::Document,
|
||||
action: exports::openidea::plugin::file_actions::FileAction,
|
||||
) -> Result<exports::openidea::plugin::file_actions::FileAction, types::Error> {
|
||||
use exports::openidea::plugin::file_actions::{CreateFile, FileOperation};
|
||||
let dir = action.data.clone().ok_or_else(|| types::Error::Invalid("no data".into()))?;
|
||||
let operations = vec![FileOperation::Create(CreateFile {
|
||||
path: format!("{dir}/later.txt"),
|
||||
text: "later".into(),
|
||||
overwrite: false,
|
||||
})];
|
||||
Ok(exports::openidea::plugin::file_actions::FileAction { operations, data: None, ..action })
|
||||
}
|
||||
}
|
||||
|
||||
export!(Misbehave);
|
||||
Binary file not shown.
@@ -1 +1 @@
|
||||
92ca920847c51036ac6dc7acd299ff9afaef9b4037e0112a0159bf50e822c7f3
|
||||
8c7e44efd87d5463fc2333adffe051c9937e80e06869c2f5ffe8b49daa49ec0d
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-test-storage"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-test-storage"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
295f568f9bc12eb0f005771c611c85d1e3622a32548ceb04e10fd395cc61fa82
|
||||
@@ -0,0 +1,16 @@
|
||||
id = "test.storage"
|
||||
name = "Storage fixture"
|
||||
version = "0.1.0"
|
||||
engines.openidea = "^1.3"
|
||||
wasm = "dist/plugin.wasm"
|
||||
|
||||
[capabilities]
|
||||
required = ["storage"]
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "save"
|
||||
title = "Save index"
|
||||
|
||||
[[contributes.commands]]
|
||||
id = "load"
|
||||
title = "Load index"
|
||||
@@ -0,0 +1,25 @@
|
||||
use openidea_plugin::{Error, Invocation, Plugin, host, register_plugin};
|
||||
|
||||
#[derive(Default)]
|
||||
struct Storage;
|
||||
|
||||
impl Plugin for Storage {
|
||||
fn run_command(&mut self, id: &str, _: &Invocation, _: Option<&str>) -> Result<Option<String>, Error> {
|
||||
match id {
|
||||
"save" => {
|
||||
host::storage::set("index", &"é".repeat(2 << 20))?;
|
||||
Ok(None)
|
||||
}
|
||||
"load" => {
|
||||
let value = host::storage::get("index")?.ok_or("missing index")?;
|
||||
if value != "é".repeat(2 << 20) {
|
||||
return Err("index changed".into());
|
||||
}
|
||||
Ok(Some(value.len().to_string()))
|
||||
}
|
||||
_ => Err(Error::NotFound(id.into())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Storage);
|
||||
+333
@@ -0,0 +1,333 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "foldhash"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "id-arena"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "leb128fmt"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
|
||||
|
||||
[[package]]
|
||||
name = "macro-string"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e7b3a5bbb2f63aaa223a671ea1bd0e7bc16bd30ce387324cf63995deaddbbd2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openidea-plugin-tables"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openidea-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "prettyplease"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasm-encoder"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4495a6f85dd94c411c749303efcbc5defade0b0cbdbc0541495241ab917a9b5a"
|
||||
dependencies = [
|
||||
"leb128fmt",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-metadata"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c86ff6668190d9fb70372b306b4f9fe8930f0e2f57ce72e4922bf2f117900949"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"indexmap",
|
||||
"wasm-encoder",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasmparser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "974d643b125b272607e2fff8012ea14a8328acd7621e4bafa7afb32500052b1b"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"hashbrown",
|
||||
"indexmap",
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b85a987ab61d365382d25c7d47e26edd48941b3bf6e3fbf76eff918bc71dab06"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"wit-bindgen-rust-macro",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-core"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775e122e390603abfacd548027016cd0113a6e2bf2f8ce6bdd3658b013456ea8"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84e26e13b9d972026778d2ffb7985736de116026fdc8d9464f966e12df2eb240"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen-rust-macro"
|
||||
version = "0.63.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16a008a0d6b4840677326beb4edbeb210a2f1b7d0183fb7cc33f466b90fd7fb6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"macro-string",
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-component"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "078def49fdb49cab405c3006e05793b3fdaf7069126ba6b9c97d01ec8c98fe4c"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bitflags",
|
||||
"indexmap",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"wasm-encoder",
|
||||
"wasm-metadata",
|
||||
"wasmparser",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-parser"
|
||||
version = "0.262.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00f0dc051466ffe46787721fca3d088536f158ae400be044f1b1c386ba9397ad"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"hashbrown",
|
||||
"id-arena",
|
||||
"indexmap",
|
||||
"log",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"unicode-ident",
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,20 @@
|
||||
[package]
|
||||
name = "openidea-plugin-tables"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
openidea-plugin = { path = "../../../../../sdk/rust/openidea-plugin" }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
panic = "abort"
|
||||
Binary file not shown.
@@ -0,0 +1 @@
|
||||
5d510fc9bdfe868bd392a8a0a46739f23db3db38bd9adb43d6b3c05d0c125c1d
|
||||
@@ -0,0 +1,18 @@
|
||||
# Test fixture: a tool window whose table the plugin sorts itself — the IDE
|
||||
# makes the column headers clickable (`other` body, kind `table`) and sends
|
||||
# `other` view-events, kind `sort`, which the plugin decodes and answers with
|
||||
# the rows in the asked order.
|
||||
id = "tables"
|
||||
name = "Tables"
|
||||
version = "0.1.0"
|
||||
description = "Test fixture: a sortable table the plugin sorts itself."
|
||||
authors = ["OpenIDEA contributors"]
|
||||
engines.openidea = "^1.0"
|
||||
wasm = "dist/plugin.wasm"
|
||||
activation = ["on-view:tables"]
|
||||
|
||||
[[contributes.views]]
|
||||
id = "tables"
|
||||
title = "Tables"
|
||||
icon = "plugin"
|
||||
location = "right"
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Test fixture: a table of file-type counts the plugin sorts itself. The
|
||||
//! headers are clickable ([`Body::sortable_table`], the plugin API's `other`
|
||||
//! body, kind `table`), the clicks arrive as `other` view-events (kind
|
||||
//! `sort`), and the rows render in the order the plugin was asked for.
|
||||
|
||||
use openidea_plugin::host;
|
||||
use openidea_plugin::views::{Body, Table, TableSort, View, ViewEvent, Viewport, Views};
|
||||
use openidea_plugin::{Error, Invocation, Plugin, register_plugin};
|
||||
|
||||
pub const VIEW: &str = "tables";
|
||||
|
||||
/// (extension, files) in no particular order, so sorting shows.
|
||||
const ROWS: [(&str, &str); 4] = [("rs", "20"), ("md", "3"), ("java", "9"), ("toml", "12")];
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Tables {
|
||||
sort: Option<TableSort>,
|
||||
}
|
||||
|
||||
impl Tables {
|
||||
/// The rows in the order `sort` asks for (a real plugin would sort its
|
||||
/// whole table and page it; the fixture is small enough to sort inline).
|
||||
fn rows(&self) -> Vec<Vec<String>> {
|
||||
let mut rows: Vec<Vec<String>> = ROWS.iter().map(|(a, b)| vec![a.to_string(), b.to_string()]).collect();
|
||||
if let Some(sort) = self.sort {
|
||||
let by_number = |r: &Vec<String>| r[1].parse::<u32>().unwrap_or(0);
|
||||
rows.sort_by(|a, b| {
|
||||
let (x, y) = (by_number(a), by_number(b));
|
||||
if sort.descending { y.cmp(&x) } else { x.cmp(&y) }
|
||||
});
|
||||
}
|
||||
rows
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for Tables {
|
||||
fn run_command(&mut self, id: &str, _ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
|
||||
Err(Error::NotFound(format!("command {id}")))
|
||||
}
|
||||
}
|
||||
|
||||
impl Views for Tables {
|
||||
fn render(&mut self, view: &str, _viewport: Option<Viewport>, _ctx: &Invocation) -> Result<View, Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
let table = Table {
|
||||
columns: vec!["ext".into(), "files".into()],
|
||||
total_rows: ROWS.len() as u32,
|
||||
first_row: 0,
|
||||
rows: self.rows(),
|
||||
editable: false,
|
||||
};
|
||||
Ok(View::new(Body::sortable_table(&table, self.sort)))
|
||||
}
|
||||
|
||||
fn handle(&mut self, view: &str, event: ViewEvent, _ctx: &Invocation) -> Result<(), Error> {
|
||||
if view != VIEW {
|
||||
return Err(Error::NotFound(view.into()));
|
||||
}
|
||||
// The host may have encoded the click for a newer SDK: the typed
|
||||
// helper reads the `other` kind `sort` this SDK knows.
|
||||
if let Some(sort) = event.sort() {
|
||||
self.sort = Some(sort);
|
||||
host::refresh_view(VIEW);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
register_plugin!(Tables, views);
|
||||
@@ -144,6 +144,24 @@ pub fn project_jdk(root: &Path, s: &Settings) -> Option<crate::java::Jdk> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Module-specific JDK from `projectStructure.modules`, falling back to [`project_jdk`].
|
||||
pub fn module_jdk(root: &Path, module: &Path, s: &Settings) -> Option<crate::java::Jdk> {
|
||||
let module_cfg = s.project_structure.module_settings(root, module);
|
||||
if let Some(cfg) = module_cfg
|
||||
&& let Some(jdk) = jdk_setting(root, &cfg.jdk)
|
||||
{
|
||||
return jdk.inspect_err(|p| tracing::warn!("module JDK {}", not_a_jdk(p))).ok();
|
||||
}
|
||||
project_jdk(root, s)
|
||||
}
|
||||
|
||||
/// Effective Java language level (`8`, `11`, `17`, `21`, `25`) for `module`, checking
|
||||
/// `projectStructure.modules` first, then `languages.java.languageLevel`.
|
||||
pub fn effective_java_language_level<'a>(root: &Path, module: &Path, s: &'a Settings) -> Option<&'a str> {
|
||||
let module_level = s.project_structure.module_settings(root, module).and_then(|m| non_empty(&m.language_level));
|
||||
module_level.or_else(|| non_empty(&s.languages.java.language_level))
|
||||
}
|
||||
|
||||
/// The JDK chosen to run jdtls (`languages.java.jdtlsJdk`): `Ok(None)` when not
|
||||
/// set, `Err` (the message) when it is not a JDK home.
|
||||
pub fn jdtls_jdk(root: &Path, s: &Settings) -> Result<Option<crate::java::Jdk>, String> {
|
||||
@@ -640,16 +658,43 @@ fn missing_serial_version(inspections: &InspectionSettings) -> Option<&'static s
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
/// Each variant has its own file, so changing an inspection restarts the server with it.
|
||||
pub fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path, inspections: &InspectionSettings) {
|
||||
configure_java_compiler_with_level(cfg, data_dir, inspections, None);
|
||||
}
|
||||
|
||||
/// Like [`configure_java_compiler`], also applying the Project Structure Java language level
|
||||
/// (`source`, `compliance`, `codegen.targetPlatform`) when configured.
|
||||
pub fn configure_java_compiler_with_level(
|
||||
cfg: &mut LanguageServerConfig,
|
||||
data_dir: &Path,
|
||||
inspections: &InspectionSettings,
|
||||
language_level: Option<&str>,
|
||||
) {
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let serial = missing_serial_version(inspections);
|
||||
let (name, content) = match serial {
|
||||
None => ("compiler-defaults.prefs".to_owned(), DEFAULTS.to_owned()),
|
||||
Some(level) => (
|
||||
format!("compiler-defaults-serial-{level}.prefs"),
|
||||
format!("{DEFAULTS}org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"),
|
||||
),
|
||||
};
|
||||
let normalized_level = language_level.map(str::trim).and_then(|l| match l {
|
||||
"8" | "1.8" => Some(("8", "1.8")),
|
||||
"11" => Some(("11", "11")),
|
||||
"17" => Some(("17", "17")),
|
||||
"21" => Some(("21", "21")),
|
||||
"25" => Some(("25", "25")),
|
||||
_ => None,
|
||||
});
|
||||
let mut stem = "compiler-defaults".to_owned();
|
||||
let mut content = DEFAULTS.to_owned();
|
||||
if let Some(level) = serial {
|
||||
stem.push_str(&format!("-serial-{level}"));
|
||||
content.push_str(&format!("org.eclipse.jdt.core.compiler.problem.missingSerialVersion={level}\n"));
|
||||
}
|
||||
if let Some((tag, jdt_ver)) = normalized_level {
|
||||
stem.push_str(&format!("-level-{tag}"));
|
||||
content.push_str(&format!(
|
||||
"org.eclipse.jdt.core.compiler.source={jdt_ver}\n\
|
||||
org.eclipse.jdt.core.compiler.compliance={jdt_ver}\n\
|
||||
org.eclipse.jdt.core.compiler.codegen.targetPlatform={jdt_ver}\n"
|
||||
));
|
||||
}
|
||||
let name = format!("{stem}.prefs");
|
||||
let file = data_dir.join("lsp/jdtls").join(name);
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
|
||||
@@ -384,6 +384,12 @@ pub fn configure_jdtls(
|
||||
// A different source model needs a clean import, including new folders.
|
||||
key.push_str(&format!("-sources-{}", text_key(&paths.to_string())));
|
||||
}
|
||||
if let Some(output) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/outputPath")) {
|
||||
key.push_str(&format!("-out-{}", text_key(&output.to_string())));
|
||||
}
|
||||
if let Some(libs) = cfg.settings.as_ref().and_then(|s| s.pointer("/java/project/referencedLibraries")) {
|
||||
key.push_str(&format!("-libs-{}", text_key(&libs.to_string())));
|
||||
}
|
||||
let dir = data_dir.join("lsp").join("jdtls").join(key);
|
||||
cfg.args.extend(["-data".to_owned(), dir.to_string_lossy().into_owned()]);
|
||||
}
|
||||
@@ -1196,7 +1202,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.7.0.jar");
|
||||
let extension = data_dir.join("java-usages-1.8.0.jar");
|
||||
let bytes = include_bytes!("../../../java/java-usages/openidea-java-usages.jar");
|
||||
if std::fs::read(&extension).is_ok_and(|old| old == bytes) {
|
||||
clean_usages_bundles(data_dir, &extension);
|
||||
|
||||
@@ -71,7 +71,8 @@ pub struct BuildDependency {
|
||||
/// `dependencies` (from the build model) are listed when their jars are in the local repositories.
|
||||
pub fn project_libraries(root: &Path, settings: &Settings, dependencies: &[BuildDependency]) -> Vec<LibraryGroup> {
|
||||
type Detect = fn(&Path, &Settings) -> Vec<LibraryGroup>;
|
||||
let detectors: [Detect; 6] = [python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups];
|
||||
let detectors: [Detect; 7] =
|
||||
[python_groups, jdk_groups, node_groups, rust_groups, go_groups, ruby_groups, project_structure_groups];
|
||||
std::thread::scope(|s| {
|
||||
let handles: Vec<_> = detectors.iter().map(|d| s.spawn(move || d(root, settings))).collect();
|
||||
let deps = s.spawn(|| dependency_groups(root, settings, dependencies));
|
||||
@@ -217,7 +218,11 @@ fn ruby_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
// ------------------------------------------------------------------ jdk
|
||||
|
||||
fn jdk_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, JAVA_MANIFESTS) else { return Vec::new() };
|
||||
let files = match manifests(root, JAVA_MANIFESTS) {
|
||||
Some(files) => files,
|
||||
None if !s.languages.java.jdk.trim().is_empty() || !s.project_structure.source_roots.is_empty() => Vec::new(),
|
||||
None => return Vec::new(),
|
||||
};
|
||||
cached(root, "jdk", s.languages.java.jdk.clone(), &files, || {
|
||||
let jdk = crate::apply::project_jdk(root, s).or_else(|| {
|
||||
crate::java::pick_jdk(&crate::java::installed_jdks(), crate::java::project_java_version(root)).cloned()
|
||||
@@ -226,6 +231,30 @@ fn jdk_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
})
|
||||
}
|
||||
|
||||
fn project_structure_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let mut out = Vec::new();
|
||||
for lib in &s.project_structure.libraries {
|
||||
let mut roots = Vec::new();
|
||||
for entry in lib.classes.iter().chain(&lib.sources) {
|
||||
let Some(path) = crate::apply::setting_path(root, entry) else { continue };
|
||||
let label = path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
if path.is_file() {
|
||||
roots.push(LibraryRoot { name: label, path: ide_vfs::archive_root(&path).into() });
|
||||
} else if path.is_dir() {
|
||||
roots.push(LibraryRoot { name: label, path: path.into() });
|
||||
}
|
||||
}
|
||||
if !roots.is_empty() {
|
||||
let name = if lib.name.trim().is_empty() { roots[0].name.clone() } else { lib.name.trim().to_owned() };
|
||||
out.push(LibraryGroup { name, kind: "library".into(), roots });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The JDK home and its sources (`lib/src.zip`, `src.zip` in JDK 8) as library roots.
|
||||
pub(crate) fn jdk_library(jdk: &crate::java::Jdk) -> Option<LibraryGroup> {
|
||||
let name = jdk_name(&jdk.home).unwrap_or_else(|| format!("JDK {}", jdk.major));
|
||||
|
||||
@@ -1784,3 +1784,61 @@ fn file_runtime_uses_nearest_virtualenv_and_explicit_project_settings_win() {
|
||||
settings.languages.python.interpreter = "selected/python".into();
|
||||
assert_eq!(crate::apply::python_interpreter_near(root, Some(&near), &settings), Some(root.join("selected/python")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_structure_module_jdk_language_level_and_external_libraries() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let root_buf = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = root_buf.as_path();
|
||||
let jdk17 = root.join("jdk17");
|
||||
let jdk21 = root.join("jdk21");
|
||||
fake_jdk(&jdk17);
|
||||
fake_jdk(&jdk21);
|
||||
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.java.jdk = jdk17.to_string_lossy().into_owned();
|
||||
settings.languages.java.language_level = "8".into();
|
||||
settings.project_structure.modules.insert(
|
||||
"service".into(),
|
||||
ide_core::settings::ModuleStructureSettings {
|
||||
jdk: jdk21.to_string_lossy().into_owned(),
|
||||
language_level: "21".into(),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(crate::apply::module_jdk(root, root, &settings).map(|j| j.home), Some(jdk17));
|
||||
assert_eq!(crate::apply::module_jdk(root, &root.join("service"), &settings).map(|j| j.home), Some(jdk21));
|
||||
assert_eq!(
|
||||
crate::apply::module_jdk(root, &root.join("nested/service"), &settings).map(|j| j.home),
|
||||
crate::apply::project_jdk(root, &settings).map(|j| j.home)
|
||||
);
|
||||
assert_eq!(crate::apply::effective_java_language_level(root, root, &settings), Some("8"));
|
||||
assert_eq!(crate::apply::effective_java_language_level(root, &root.join("service"), &settings), Some("21"));
|
||||
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
crate::apply::configure_java_compiler_with_level(&mut cfg, root, &settings.inspections, Some("8"));
|
||||
let prefs_files: Vec<_> = std::fs::read_dir(root.join("lsp/jdtls"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| std::fs::read_to_string(e.path()).unwrap())
|
||||
.collect();
|
||||
assert_eq!(prefs_files.len(), 1);
|
||||
assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.source=1.8"));
|
||||
assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.compliance=1.8"));
|
||||
assert!(prefs_files[0].contains("org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8"));
|
||||
|
||||
let lib_dir = root.join("lib");
|
||||
std::fs::create_dir_all(&lib_dir).unwrap();
|
||||
let jar_file = lib_dir.join("servlet-api.jar");
|
||||
std::fs::write(&jar_file, b"").unwrap();
|
||||
settings.project_structure.libraries.push(ide_core::settings::ProjectLibrary {
|
||||
name: "servlet-api".into(),
|
||||
classes: vec!["lib/servlet-api.jar".into()],
|
||||
sources: vec![],
|
||||
javadocs: vec![],
|
||||
scope: ide_core::settings::DependencyScope::Provided,
|
||||
});
|
||||
let groups = crate::libraries::project_libraries(root, &settings, &[]);
|
||||
assert!(groups.iter().any(|g| g.name == "servlet-api" && g.kind == "library" && g.roots.len() == 1));
|
||||
}
|
||||
@@ -18,7 +18,6 @@ uuid.workspace = true
|
||||
keyring.workspace = true
|
||||
keyring-core.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
|
||||
|
||||
+177
-11
@@ -1,11 +1,13 @@
|
||||
//! An authenticated SSH connection (russh), optionally through `ProxyJump`
|
||||
//! hosts, and what runs over it: commands, processes, SFTP and TCP channels.
|
||||
//! hosts or a `ProxyCommand`, and what runs over it: commands, processes, SFTP
|
||||
//! and TCP channels.
|
||||
//!
|
||||
//! Host keys are checked against `known_hosts`; an unknown key fails the
|
||||
//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user,
|
||||
//! and a changed key is always refused.
|
||||
//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user
|
||||
//! (or is recorded silently under `StrictHostKeyChecking accept-new`), and a
|
||||
//! changed key is refused unless host key checking is off.
|
||||
|
||||
use crate::target::{Hop, SshFiles, SshTarget, expand, home, resolve_hops};
|
||||
use crate::target::{Hop, HostKeyPolicy, SshFiles, SshTarget, expand, home, resolve_hops};
|
||||
use ide_core::remote_settings::SshAuth;
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
@@ -27,6 +29,7 @@ pub struct UnknownHostKey {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub key: PublicKey,
|
||||
pub known_hosts: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl UnknownHostKey {
|
||||
@@ -37,6 +40,7 @@ impl UnknownHostKey {
|
||||
|
||||
/// Records the key in `known_hosts` (blocking).
|
||||
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
|
||||
let known_hosts = self.known_hosts.as_deref().unwrap_or(known_hosts);
|
||||
if let Some(dir) = known_hosts.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
@@ -128,12 +132,18 @@ impl Session {
|
||||
secret: Option<&str>,
|
||||
files: &SshFiles,
|
||||
) -> Result<Session, ConnectError> {
|
||||
let hops = resolve_hops(target, files.config.as_deref())?;
|
||||
// Resolving may run `Match exec` commands from the config.
|
||||
let hops = {
|
||||
let (target, config) = (target.clone(), files.config.clone());
|
||||
tokio::task::spawn_blocking(move || resolve_hops(&target, config.as_deref()))
|
||||
.await
|
||||
.map_err(|e| io(format!("cannot read the SSH config: {e}")))??
|
||||
};
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
let last = i == hops.len() - 1;
|
||||
let mut handle = match sessions.last() {
|
||||
None => connect::<tokio::net::TcpStream>(hop, &files.known_hosts, None).await?,
|
||||
None => connect_first_hop(hop, &files.known_hosts).await?,
|
||||
Some(prev) => {
|
||||
let channel = prev
|
||||
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
||||
@@ -366,6 +376,7 @@ pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
known_hosts: PathBuf,
|
||||
host_key_policy: HostKeyPolicy,
|
||||
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
||||
}
|
||||
|
||||
@@ -373,6 +384,9 @@ impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
if self.host_key_policy == HostKeyPolicy::Off {
|
||||
return Ok(true);
|
||||
}
|
||||
let key = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
||||
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
||||
@@ -380,6 +394,9 @@ impl client::Handler for Client {
|
||||
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
||||
Ok(true) => return Ok(true),
|
||||
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
||||
Ok(false) | Err(_) if self.host_key_policy == HostKeyPolicy::AcceptNew && self.learn(&key) => {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
||||
};
|
||||
*self.issue.lock() = Some(issue);
|
||||
@@ -387,6 +404,143 @@ impl client::Handler for Client {
|
||||
}
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn learn(&self, key: &PublicKey) -> bool {
|
||||
if let Some(dir) = self.known_hosts.parent() {
|
||||
let _ = std::fs::create_dir_all(dir);
|
||||
}
|
||||
known_hosts::learn_known_hosts_path(&self.host, self.port, key, &self.known_hosts).is_ok()
|
||||
}
|
||||
}
|
||||
|
||||
struct ProxyStream {
|
||||
child: tokio::process::Child,
|
||||
stdin: Option<tokio::process::ChildStdin>,
|
||||
stdout: Option<tokio::process::ChildStdout>,
|
||||
stderr: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
impl Drop for ProxyStream {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pid) = self.child.id() {
|
||||
ide_core::process::kill_tree_of(pid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncRead for ProxyStream {
|
||||
fn poll_read(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &mut tokio::io::ReadBuf<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.stdout.as_mut() {
|
||||
Some(stdout) => std::pin::Pin::new(stdout).poll_read(cx, buf),
|
||||
None => std::task::Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl tokio::io::AsyncWrite for ProxyStream {
|
||||
fn poll_write(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
match self.stdin.as_mut() {
|
||||
Some(stdin) => std::pin::Pin::new(stdin).poll_write(cx, buf),
|
||||
None => std::task::Poll::Ready(Ok(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_flush(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
match self.stdin.as_mut() {
|
||||
Some(stdin) => std::pin::Pin::new(stdin).poll_flush(cx),
|
||||
None => std::task::Poll::Ready(Ok(())),
|
||||
}
|
||||
}
|
||||
|
||||
fn poll_shutdown(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
if let Some(stdin) = self.stdin.as_mut()
|
||||
&& let std::task::Poll::Pending = std::pin::Pin::new(stdin).poll_shutdown(cx)
|
||||
{
|
||||
return std::task::Poll::Pending;
|
||||
}
|
||||
self.stdin = None;
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_proxy_command(command: &str) -> Result<ProxyStream, std::io::Error> {
|
||||
#[cfg(unix)]
|
||||
let mut cmd = {
|
||||
let mut c = tokio::process::Command::new("/bin/sh");
|
||||
c.arg("-c").arg(command);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = tokio::process::Command::new("cmd");
|
||||
c.arg("/C").arg(command);
|
||||
c
|
||||
};
|
||||
ide_core::process::prepare(cmd.as_std_mut());
|
||||
ide_core::process::own_group(cmd.as_std_mut());
|
||||
cmd.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
let mut child = cmd.spawn()?;
|
||||
let stdin = child.stdin.take();
|
||||
let stdout = child.stdout.take();
|
||||
let stderr_buf = Arc::new(Mutex::new(Vec::new()));
|
||||
if let Some(mut stderr) = child.stderr.take() {
|
||||
let sink = stderr_buf.clone();
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut chunk = [0u8; 1024];
|
||||
while let Ok(n) = stderr.read(&mut chunk).await {
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
let mut g = sink.lock();
|
||||
if g.len() < 4096 {
|
||||
let take = (4096 - g.len()).min(n);
|
||||
g.extend_from_slice(&chunk[..take]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(ProxyStream { child, stdin, stdout, stderr: stderr_buf })
|
||||
}
|
||||
|
||||
async fn connect_first_hop(hop: &Hop, known_hosts: &Path) -> Result<Handle<Client>, ConnectError> {
|
||||
if let Some(command) = hop.proxy_command.as_deref() {
|
||||
let stream = spawn_proxy_command(command).map_err(|e| {
|
||||
ConnectError::Other(io(format!("cannot start ProxyCommand for {}:{}: {e}", hop.host, hop.port)))
|
||||
})?;
|
||||
let stderr = stream.stderr.clone();
|
||||
connect(hop, known_hosts, Some(stream)).await.map_err(|e| match e {
|
||||
ConnectError::Other(mut err) => {
|
||||
let detail = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
if !detail.is_empty() {
|
||||
err.message = format!("{}: {detail}", err.message);
|
||||
}
|
||||
ConnectError::Other(err)
|
||||
}
|
||||
other => other,
|
||||
})
|
||||
} else {
|
||||
connect::<tokio::net::TcpStream>(hop, known_hosts, None).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, ConnectError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
@@ -399,16 +553,25 @@ where
|
||||
..Default::default()
|
||||
});
|
||||
let issue = Arc::new(Mutex::new(None));
|
||||
let handler =
|
||||
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
|
||||
let effective_known_hosts = hop.known_hosts.as_deref().unwrap_or(known_hosts).to_owned();
|
||||
let handler = Client {
|
||||
host: hop.host.clone(),
|
||||
port: hop.port,
|
||||
known_hosts: effective_known_hosts,
|
||||
host_key_policy: hop.host_key_policy,
|
||||
issue: issue.clone(),
|
||||
};
|
||||
let result = match stream {
|
||||
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
||||
Some(s) => client::connect_stream(config, s, handler).await,
|
||||
};
|
||||
result.map_err(|e| match issue.lock().take() {
|
||||
Some(HostKeyIssue::Unknown(key)) => {
|
||||
ConnectError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key }))
|
||||
}
|
||||
Some(HostKeyIssue::Unknown(key)) => ConnectError::UnknownHostKey(Box::new(UnknownHostKey {
|
||||
host: hop.host.clone(),
|
||||
port: hop.port,
|
||||
key,
|
||||
known_hosts: hop.known_hosts.clone(),
|
||||
})),
|
||||
Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
@@ -487,6 +650,9 @@ async fn authenticate(
|
||||
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
SshAuth::OpenSsh => {
|
||||
if handle.authenticate_none(user).await.map(|r| r.success()).unwrap_or(false) {
|
||||
return Ok(());
|
||||
}
|
||||
if with_agent(handle, user).await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
+531
-27
@@ -4,6 +4,9 @@
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const MATCH_EXEC_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// An SSH host and how to authenticate to it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
@@ -44,6 +47,20 @@ pub(crate) struct Hop {
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub identity_files: Vec<PathBuf>,
|
||||
pub proxy_command: Option<String>,
|
||||
pub host_key_policy: HostKeyPolicy,
|
||||
pub known_hosts: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// `StrictHostKeyChecking`: what happens to a host key missing from `known_hosts`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum HostKeyPolicy {
|
||||
/// The user decides on an unknown key; a changed key is refused (`yes`, `ask`).
|
||||
Ask,
|
||||
/// An unknown key is recorded without asking; a changed key is refused (`accept-new`).
|
||||
AcceptNew,
|
||||
/// Every key is accepted (`no`, `off`, or no `known_hosts` file to check against).
|
||||
Off,
|
||||
}
|
||||
|
||||
impl Hop {
|
||||
@@ -68,14 +85,408 @@ fn local_user() -> String {
|
||||
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RawConfig {
|
||||
hostname: Option<String>,
|
||||
user: Option<String>,
|
||||
port: Option<u16>,
|
||||
identity_files: Vec<String>,
|
||||
proxy_jump: Option<String>,
|
||||
proxy_command: Option<String>,
|
||||
host_key_policy: Option<HostKeyPolicy>,
|
||||
user_known_hosts_file: Option<String>,
|
||||
}
|
||||
|
||||
fn strip_quotes(s: &str) -> &str {
|
||||
let s = s.trim();
|
||||
if s.len() >= 2 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\''))) {
|
||||
&s[1..s.len() - 1]
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits an `ssh_config` line into `(keyword, rest)`, supporting both whitespace and `=` separators.
|
||||
fn split_directive(line: &str) -> Option<(&str, &str)> {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
return None;
|
||||
}
|
||||
let end = line.find(|c: char| c.is_ascii_whitespace() || c == '=')?;
|
||||
let key = &line[..end];
|
||||
let rest = line[end..].trim_start();
|
||||
let rest = rest.strip_prefix('=').map_or(rest, str::trim_start);
|
||||
Some((key, rest))
|
||||
}
|
||||
|
||||
/// Splits whitespace-delimited arguments while respecting single and double quotes.
|
||||
fn split_args(input: &str) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut quote: Option<char> = None;
|
||||
let mut in_token = false;
|
||||
for ch in input.chars() {
|
||||
match quote {
|
||||
Some(q) if ch == q => quote = None,
|
||||
Some(_) => cur.push(ch),
|
||||
None if ch == '"' || ch == '\'' => {
|
||||
quote = Some(ch);
|
||||
in_token = true;
|
||||
}
|
||||
None if ch.is_ascii_whitespace() => {
|
||||
if in_token {
|
||||
args.push(std::mem::take(&mut cur));
|
||||
in_token = false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
cur.push(ch);
|
||||
in_token = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if in_token {
|
||||
args.push(cur);
|
||||
}
|
||||
args
|
||||
}
|
||||
|
||||
/// Matches `text` against a glob `pattern` with `*` and `?` wildcards.
|
||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||
let p: Vec<char> = pattern.chars().collect();
|
||||
let t: Vec<char> = text.chars().collect();
|
||||
let (mut pi, mut ti) = (0, 0);
|
||||
let (mut star_pi, mut star_ti) = (None, 0);
|
||||
while ti < t.len() {
|
||||
if pi < p.len() && (p[pi] == '?' || p[pi].eq_ignore_ascii_case(&t[ti])) {
|
||||
pi += 1;
|
||||
ti += 1;
|
||||
} else if pi < p.len() && p[pi] == '*' {
|
||||
star_pi = Some(pi);
|
||||
pi += 1;
|
||||
star_ti = ti;
|
||||
} else if let Some(sp) = star_pi {
|
||||
pi = sp + 1;
|
||||
star_ti += 1;
|
||||
ti = star_ti;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while pi < p.len() && p[pi] == '*' {
|
||||
pi += 1;
|
||||
}
|
||||
pi == p.len()
|
||||
}
|
||||
|
||||
/// OpenSSH pattern-list matching: any matching `!pattern` rejects, else any matching pattern accepts.
|
||||
fn pattern_list_matches<'a>(patterns: impl IntoIterator<Item = &'a str>, candidate: &str) -> bool {
|
||||
let mut matched = false;
|
||||
for raw in patterns {
|
||||
if raw.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if let Some(neg) = raw.strip_prefix('!') {
|
||||
if glob_match(neg, candidate) {
|
||||
return false;
|
||||
}
|
||||
} else if glob_match(raw, candidate) {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
matched
|
||||
}
|
||||
|
||||
/// Expands OpenSSH percent tokens (`%h`, `%H`, `%n`, `%p`, `%r`, `%u`, `%d`, `%%`).
|
||||
fn expand_tokens(template: &str, alias: &str, host: &str, port: u16, remote_user: &str, local_user: &str) -> String {
|
||||
let mut out = String::with_capacity(template.len());
|
||||
let mut chars = template.chars();
|
||||
let home_dir = home().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch != '%' {
|
||||
out.push(ch);
|
||||
continue;
|
||||
}
|
||||
match chars.next() {
|
||||
Some('%') => out.push('%'),
|
||||
Some('h' | 'H') => out.push_str(host),
|
||||
Some('n') => out.push_str(alias),
|
||||
Some('p') => out.push_str(&port.to_string()),
|
||||
Some('r') => out.push_str(remote_user),
|
||||
Some('u') => out.push_str(local_user),
|
||||
Some('d') => out.push_str(&home_dir),
|
||||
Some(other) => {
|
||||
out.push('%');
|
||||
out.push(other);
|
||||
}
|
||||
None => out.push('%'),
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Runs a `Match exec` command; one that does not finish in time counts as not matching.
|
||||
fn eval_match_exec(command: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("/bin/sh");
|
||||
c.arg("-c").arg(command);
|
||||
c
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let mut cmd = {
|
||||
let mut c = std::process::Command::new("cmd");
|
||||
c.arg("/C").arg(command);
|
||||
c
|
||||
};
|
||||
ide_core::process::prepare(&mut cmd);
|
||||
ide_core::process::own_group(&mut cmd);
|
||||
cmd.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
|
||||
let Ok(mut child) = cmd.spawn() else { return false };
|
||||
let deadline = Instant::now() + MATCH_EXEC_TIMEOUT;
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return status.success(),
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)),
|
||||
_ => {
|
||||
ide_core::process::kill_tree(&mut child);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn eval_match(
|
||||
value: &str,
|
||||
alias: &str,
|
||||
port_override: Option<u16>,
|
||||
user_override: Option<&str>,
|
||||
out: &RawConfig,
|
||||
) -> bool {
|
||||
let tokens = split_args(value);
|
||||
if tokens.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let local = local_user();
|
||||
let host = out.hostname.as_deref().unwrap_or(alias);
|
||||
let port = port_override.or(out.port).unwrap_or(22);
|
||||
let user = user_override.map(str::trim).filter(|s| !s.is_empty()).or(out.user.as_deref()).unwrap_or(&local);
|
||||
|
||||
let mut i = 0;
|
||||
while i < tokens.len() {
|
||||
let tok = tokens[i].as_str();
|
||||
let (raw_key, inline_val) = match tok.split_once('=') {
|
||||
Some((k, v)) => (k, Some(v)),
|
||||
None => (tok, None),
|
||||
};
|
||||
let (negated, key) = match raw_key.strip_prefix('!') {
|
||||
Some(k) => (true, k.to_ascii_lowercase()),
|
||||
None => (false, raw_key.to_ascii_lowercase()),
|
||||
};
|
||||
match key.as_str() {
|
||||
"all" | "canonical" | "final" => {
|
||||
if negated {
|
||||
return false;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
"host" | "originalhost" | "user" | "localuser" | "exec" => {
|
||||
let arg = match inline_val {
|
||||
Some(v) => {
|
||||
i += 1;
|
||||
v
|
||||
}
|
||||
None => {
|
||||
let Some(next) = tokens.get(i + 1) else { return false };
|
||||
i += 2;
|
||||
next.as_str()
|
||||
}
|
||||
};
|
||||
let ok = match key.as_str() {
|
||||
"host" => pattern_list_matches(arg.split(','), host),
|
||||
"originalhost" => pattern_list_matches(arg.split(','), alias),
|
||||
"user" => pattern_list_matches(arg.split(','), user),
|
||||
"localuser" => pattern_list_matches(arg.split(','), &local),
|
||||
"exec" => {
|
||||
let cmd = expand_tokens(arg, alias, host, port, user, &local);
|
||||
eval_match_exec(&cmd)
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
if ok == negated {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Expands a path pattern that may contain `*` or `?` in its path segments.
|
||||
fn expand_include_glob(pattern: &Path) -> Vec<PathBuf> {
|
||||
use std::path::Component;
|
||||
let mut paths = vec![PathBuf::new()];
|
||||
for comp in pattern.components() {
|
||||
match comp {
|
||||
Component::Normal(os) => {
|
||||
let s = os.to_string_lossy();
|
||||
if s.contains('*') || s.contains('?') {
|
||||
let mut next = Vec::new();
|
||||
for base in &paths {
|
||||
let dir = if base.as_os_str().is_empty() { Path::new(".") } else { base.as_path() };
|
||||
let Ok(entries) = std::fs::read_dir(dir) else { continue };
|
||||
let mut matched: Vec<PathBuf> = entries
|
||||
.filter_map(Result::ok)
|
||||
.filter(|e| glob_match(&s, &e.file_name().to_string_lossy()))
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
matched.sort();
|
||||
next.extend(matched);
|
||||
}
|
||||
paths = next;
|
||||
} else {
|
||||
for p in &mut paths {
|
||||
p.push(os);
|
||||
}
|
||||
}
|
||||
}
|
||||
other => {
|
||||
for p in &mut paths {
|
||||
p.push(other.as_os_str());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.into_iter().filter(|p| p.is_file()).collect()
|
||||
}
|
||||
|
||||
fn parse_config_file(
|
||||
path: &Path,
|
||||
alias: &str,
|
||||
port_override: Option<u16>,
|
||||
user_override: Option<&str>,
|
||||
out: &mut RawConfig,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth >= 8 {
|
||||
return;
|
||||
}
|
||||
let Ok(contents) = std::fs::read_to_string(path) else { return };
|
||||
let base_dir = path.parent().unwrap_or_else(|| Path::new("."));
|
||||
let mut active = true;
|
||||
for line in contents.lines() {
|
||||
let Some((key, value)) = split_directive(line) else { continue };
|
||||
match key.to_ascii_lowercase().as_str() {
|
||||
"host" => {
|
||||
let patterns = split_args(value);
|
||||
active = pattern_list_matches(patterns.iter().map(String::as_str), alias);
|
||||
}
|
||||
"match" => {
|
||||
active = eval_match(value, alias, port_override, user_override, out);
|
||||
}
|
||||
_ if !active => {}
|
||||
"include" => {
|
||||
for raw in split_args(value) {
|
||||
let expanded = expand(&raw);
|
||||
let pat = if expanded.is_relative() { base_dir.join(expanded) } else { expanded };
|
||||
for file in expand_include_glob(&pat) {
|
||||
parse_config_file(&file, alias, port_override, user_override, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
"hostname" => {
|
||||
if out.hostname.is_none() {
|
||||
let raw = strip_quotes(value);
|
||||
out.hostname = Some(raw.replace("%h", alias).replace("%%", "%"));
|
||||
}
|
||||
}
|
||||
"user" => {
|
||||
if out.user.is_none() {
|
||||
out.user = Some(strip_quotes(value).to_owned());
|
||||
}
|
||||
}
|
||||
"port" => {
|
||||
if out.port.is_none() {
|
||||
out.port = strip_quotes(value).parse().ok();
|
||||
}
|
||||
}
|
||||
"identityfile" => {
|
||||
let raw = strip_quotes(value);
|
||||
if !raw.is_empty() && !raw.eq_ignore_ascii_case("none") {
|
||||
out.identity_files.push(raw.to_owned());
|
||||
}
|
||||
}
|
||||
// Whichever of `ProxyJump` and `ProxyCommand` comes first wins, as in OpenSSH.
|
||||
"proxyjump" => {
|
||||
if out.proxy_jump.is_none() && out.proxy_command.is_none() {
|
||||
out.proxy_jump = Some(strip_quotes(value).to_owned());
|
||||
}
|
||||
}
|
||||
"proxycommand" => {
|
||||
if out.proxy_command.is_none() && out.proxy_jump.is_none() {
|
||||
out.proxy_command = Some(strip_quotes(value).to_owned());
|
||||
}
|
||||
}
|
||||
"stricthostkeychecking" => {
|
||||
if out.host_key_policy.is_none() {
|
||||
out.host_key_policy = Some(match strip_quotes(value).to_ascii_lowercase().as_str() {
|
||||
"no" | "off" | "false" => HostKeyPolicy::Off,
|
||||
"accept-new" => HostKeyPolicy::AcceptNew,
|
||||
_ => HostKeyPolicy::Ask,
|
||||
});
|
||||
}
|
||||
}
|
||||
"userknownhostsfile" => {
|
||||
if out.user_known_hosts_file.is_none()
|
||||
&& let Some(first) = split_args(value).into_iter().next()
|
||||
{
|
||||
out.user_known_hosts_file = Some(first);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
|
||||
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
|
||||
let c = config
|
||||
.and_then(|p| russh_config::parse_path(p, alias).ok())
|
||||
.unwrap_or_else(|| russh_config::Config::default(alias));
|
||||
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
|
||||
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
|
||||
(hop, c.host_config.proxy_jump.clone())
|
||||
fn config_hop(
|
||||
config: Option<&Path>,
|
||||
alias: &str,
|
||||
port_override: Option<u16>,
|
||||
user_override: Option<&str>,
|
||||
) -> (Hop, Option<String>) {
|
||||
let mut raw = RawConfig::default();
|
||||
if let Some(p) = config {
|
||||
parse_config_file(p, alias, port_override, user_override, &mut raw, 0);
|
||||
}
|
||||
let local = local_user();
|
||||
let host = raw.hostname.unwrap_or_else(|| alias.to_owned());
|
||||
let port = port_override.or(raw.port).unwrap_or(22);
|
||||
let user = user_override
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.or(raw.user)
|
||||
.unwrap_or_else(|| local.clone());
|
||||
let identity_files =
|
||||
raw.identity_files.iter().map(|p| expand(&expand_tokens(p, alias, &host, port, &user, &local))).collect();
|
||||
let proxy_command = raw
|
||||
.proxy_command
|
||||
.filter(|c| !c.is_empty() && !c.eq_ignore_ascii_case("none"))
|
||||
.map(|c| expand_tokens(&c, alias, &host, port, &user, &local));
|
||||
let proxy_jump = raw.proxy_jump.filter(|j| !j.is_empty() && !j.eq_ignore_ascii_case("none"));
|
||||
let (known_hosts, null_known_hosts) = match raw.user_known_hosts_file.as_deref() {
|
||||
Some(kh) if kh.eq_ignore_ascii_case("none") || kh == "/dev/null" || kh.eq_ignore_ascii_case("nul") => {
|
||||
(None, true)
|
||||
}
|
||||
Some(kh) => (Some(expand(&expand_tokens(kh, alias, &host, port, &user, &local))), false),
|
||||
None => (None, false),
|
||||
};
|
||||
let host_key_policy =
|
||||
if null_known_hosts { HostKeyPolicy::Off } else { raw.host_key_policy.unwrap_or(HostKeyPolicy::Ask) };
|
||||
let hop = Hop { host, port, user, identity_files, proxy_command, host_key_policy, known_hosts };
|
||||
(hop, proxy_jump)
|
||||
}
|
||||
|
||||
/// `[user@]host[:port]` of a `ProxyJump` entry.
|
||||
@@ -88,13 +499,7 @@ fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
||||
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
||||
_ => (rest, None),
|
||||
};
|
||||
let (mut hop, _) = config_hop(config, host);
|
||||
if let Some(u) = user {
|
||||
hop.user = u.to_owned();
|
||||
}
|
||||
if let Some(p) = port {
|
||||
hop.port = p;
|
||||
}
|
||||
let (hop, _) = config_hop(config, host, port, user);
|
||||
hop
|
||||
}
|
||||
|
||||
@@ -104,21 +509,30 @@ pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResu
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("SSH host is empty"));
|
||||
}
|
||||
let (mut hop, proxy_jump) = if target.auth == SshAuth::OpenSsh {
|
||||
config_hop(config, host)
|
||||
let user_override = Some(target.user.trim()).filter(|u| !u.is_empty());
|
||||
let (hop, proxy_jump) = if target.auth == SshAuth::OpenSsh {
|
||||
config_hop(config, host, target.port, user_override)
|
||||
} else {
|
||||
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
|
||||
(
|
||||
Hop {
|
||||
host: host.to_owned(),
|
||||
port: target.port.unwrap_or(22),
|
||||
user: user_override.map(str::to_owned).unwrap_or_else(local_user),
|
||||
identity_files: Vec::new(),
|
||||
proxy_command: None,
|
||||
host_key_policy: HostKeyPolicy::Ask,
|
||||
known_hosts: None,
|
||||
},
|
||||
None,
|
||||
)
|
||||
};
|
||||
let mut hops: Vec<Hop> = if hop.proxy_command.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
proxy_jump
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if let Some(p) = target.port {
|
||||
hop.port = p;
|
||||
}
|
||||
if !target.user.trim().is_empty() {
|
||||
hop.user = target.user.trim().to_owned();
|
||||
}
|
||||
let mut hops: Vec<Hop> = proxy_jump
|
||||
.filter(|j| !j.eq_ignore_ascii_case("none"))
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default();
|
||||
hops.push(hop);
|
||||
Ok(hops)
|
||||
}
|
||||
@@ -173,6 +587,96 @@ mod tests {
|
||||
assert!(resolve_hops(&SshTarget::default(), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_include_match_equals_and_proxy_command() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let sub = tmp.path().join("ssh.d");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(
|
||||
sub.join("coder.conf"),
|
||||
"Host coder-vscode--*\n ProxyCommand /bin/coder ssh --stdio %h\n StrictHostKeyChecking no\n UserKnownHostsFile /dev/null\n",
|
||||
)
|
||||
.unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Include \"ssh.d/*.conf\"\n\nHost *.coder\n\tConnectTimeout=0\n\tStrictHostKeyChecking=no\n\tUserKnownHostsFile=/dev/null\n\nMatch host *.coder !exec \"false\"\n\tProxyCommand /opt/homebrew/bin/coder --global-config \"/path with spaces/coderv2\" ssh --stdio --hostname-suffix coder %h %p %r\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let target = SshTarget {
|
||||
host: "deng-devbox.coder".into(),
|
||||
port: Some(22),
|
||||
user: "dengliu".into(),
|
||||
auth: SshAuth::OpenSsh,
|
||||
..Default::default()
|
||||
};
|
||||
let hops = resolve_hops(&target, Some(&config)).unwrap();
|
||||
assert_eq!(hops.len(), 1);
|
||||
assert_eq!(hops[0].host, "deng-devbox.coder");
|
||||
assert_eq!(hops[0].port, 22);
|
||||
assert_eq!(hops[0].user, "dengliu");
|
||||
assert_eq!(hops[0].host_key_policy, HostKeyPolicy::Off);
|
||||
assert_eq!(hops[0].known_hosts, None);
|
||||
assert_eq!(
|
||||
hops[0].proxy_command.as_deref(),
|
||||
Some(
|
||||
"/opt/homebrew/bin/coder --global-config \"/path with spaces/coderv2\" ssh --stdio --hostname-suffix coder deng-devbox.coder 22 dengliu"
|
||||
)
|
||||
);
|
||||
|
||||
let included = SshTarget {
|
||||
host: "coder-vscode--devbox".into(),
|
||||
user: "coder".into(),
|
||||
auth: SshAuth::OpenSsh,
|
||||
..Default::default()
|
||||
};
|
||||
let hops = resolve_hops(&included, Some(&config)).unwrap();
|
||||
assert_eq!(hops[0].proxy_command.as_deref(), Some("/bin/coder ssh --stdio coder-vscode--devbox"));
|
||||
assert_eq!(hops[0].host_key_policy, HostKeyPolicy::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_first_of_proxy_jump_and_proxy_command_wins() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host a\n ProxyJump bastion\n ProxyCommand nc %h %p\nHost b\n ProxyCommand nc %h %p\n ProxyJump bastion\n",
|
||||
)
|
||||
.unwrap();
|
||||
let target = |host: &str| SshTarget { host: host.into(), auth: SshAuth::OpenSsh, ..Default::default() };
|
||||
let hops = resolve_hops(&target("a"), Some(&config)).unwrap();
|
||||
assert_eq!(hops.len(), 2);
|
||||
assert_eq!(hops[0].host, "bastion");
|
||||
assert_eq!(hops[1].proxy_command, None);
|
||||
let hops = resolve_hops(&target("b"), Some(&config)).unwrap();
|
||||
assert_eq!(hops.len(), 1);
|
||||
assert_eq!(hops[0].proxy_command.as_deref(), Some("nc b 22"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_host_key_checking_values() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host new\n StrictHostKeyChecking accept-new\nHost off\n StrictHostKeyChecking=no\n\
|
||||
Host ask\n StrictHostKeyChecking ask\nHost null\n UserKnownHostsFile /dev/null\n\
|
||||
Match exec \"exit 1\"\n StrictHostKeyChecking no\n",
|
||||
)
|
||||
.unwrap();
|
||||
let policy = |host: &str| {
|
||||
let target = SshTarget { host: host.into(), auth: SshAuth::OpenSsh, ..Default::default() };
|
||||
resolve_hops(&target, Some(&config)).unwrap()[0].host_key_policy
|
||||
};
|
||||
assert_eq!(policy("new"), HostKeyPolicy::AcceptNew);
|
||||
assert_eq!(policy("off"), HostKeyPolicy::Off);
|
||||
assert_eq!(policy("ask"), HostKeyPolicy::Ask);
|
||||
assert_eq!(policy("null"), HostKeyPolicy::Off);
|
||||
assert_eq!(policy("other"), HostKeyPolicy::Ask);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openssh_arguments() {
|
||||
let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() };
|
||||
|
||||
@@ -36,6 +36,10 @@ impl server::Server for Sshd {
|
||||
impl server::Handler for Sshd {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_none(&mut self, user: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "coder" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
@@ -292,3 +296,46 @@ async fn upload_reports_a_command_that_stops_reading() {
|
||||
.unwrap_err();
|
||||
assert!(err.message.contains("oops"), "{}", err.message);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn openssh_proxy_command_and_none_authentication() {
|
||||
let mut env = env().await;
|
||||
let config = env._tmp.path().join("ssh_config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
format!(
|
||||
"Host *.coder\n\tStrictHostKeyChecking=no\n\tUserKnownHostsFile=/dev/null\n\tProxyCommand nc 127.0.0.1 {}\n",
|
||||
env.ssh_port
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
env.files.config = Some(config);
|
||||
let target = SshTarget {
|
||||
host: "deng-devbox.coder".into(),
|
||||
port: Some(22),
|
||||
user: "coder".into(),
|
||||
auth: SshAuth::OpenSsh,
|
||||
..Default::default()
|
||||
};
|
||||
let session = env.connect(&target, None).await.expect("ProxyCommand + none auth should connect");
|
||||
let out = session.exec("uname -srm", None).await.unwrap();
|
||||
assert_eq!(out.stdout_str(), "ran: uname -srm");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn accept_new_records_an_unknown_key_and_still_refuses_a_changed_one() {
|
||||
let mut env = env().await;
|
||||
let config = env._tmp.path().join("ssh_config");
|
||||
std::fs::write(&config, "Host 127.0.0.1\n\tStrictHostKeyChecking accept-new\n").unwrap();
|
||||
env.files.config = Some(config);
|
||||
let target = SshTarget { user: "coder".into(), ..env.target(SshAuth::OpenSsh) };
|
||||
assert!(env.connect(&target, None).await.is_ok());
|
||||
let recorded = std::fs::read_to_string(&env.files.known_hosts).unwrap();
|
||||
assert!(recorded.trim_start().starts_with(&format!("[127.0.0.1]:{} ssh-ed25519 ", env.ssh_port)), "{recorded}");
|
||||
|
||||
let other = key("client", None).public_key().to_openssh().unwrap();
|
||||
std::fs::write(&env.files.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
||||
let msg = denied(env.connect(&target, None).await);
|
||||
assert!(msg.contains("has changed"), "{msg}");
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
pub use text_encoding::TextEncoding;
|
||||
pub use write::PendingChange;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -1069,6 +1070,17 @@ impl Repo {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// What [`Repo::commit_with`] would commit with `options`, file by file, without
|
||||
/// committing or touching the index: patches against HEAD, `max_bytes` in all.
|
||||
pub fn pending_changes<E: Into<TextEncoding>>(
|
||||
&self,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<Vec<PendingChange>> {
|
||||
let _scope = crate::ExecutionScope::enter(self.execution_allowed.load(std::sync::atomic::Ordering::Acquire));
|
||||
write::pending_changes(self, options, max_bytes, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
|
||||
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
|
||||
/// detached.
|
||||
|
||||
+140
-9
@@ -372,10 +372,10 @@ pub(crate) fn commit(
|
||||
args.extend(["-F", "-"]);
|
||||
Some(message.as_bytes())
|
||||
};
|
||||
if options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty() {
|
||||
run(r.root(), &args, stdin)?;
|
||||
} else {
|
||||
if selects(options) {
|
||||
commit_selected(r, &args, stdin, options, encoding)?;
|
||||
} else {
|
||||
run(r.root(), &args, stdin)?;
|
||||
}
|
||||
// A commit concluding a merge brings back the local changes put aside for it.
|
||||
let _ = crate::preserve::restore(r);
|
||||
@@ -393,9 +393,13 @@ impl Drop for TempIndex {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `options` commit something else than the index as it is.
|
||||
fn selects(options: &CommitOptions) -> bool {
|
||||
!(options.partial.is_empty() && options.stage.is_empty() && options.exclude.is_empty())
|
||||
}
|
||||
|
||||
/// Commits a selection of the changes through a copy of the index (`GIT_INDEX_FILE`):
|
||||
/// `exclude` goes back to HEAD in the copy, `stage` takes its working-tree content and
|
||||
/// `partial` only the chosen hunks. The working tree and the user's index are left alone
|
||||
/// see [`select_into`]. The working tree and the user's index are left alone
|
||||
/// except that the committed files' entries end up matching the new commit, so changes
|
||||
/// left out keep their staged state.
|
||||
fn commit_selected(
|
||||
@@ -406,6 +410,23 @@ fn commit_selected(
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
|
||||
let env = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
|
||||
let rels = select_into(r, &tmp, options, encoding)?;
|
||||
cli::run_env(r.root(), commit_args, stdin, &env)?;
|
||||
// The user's index still holds the partial files' previous entries: align them with
|
||||
// the commit so the remaining changes show as unstaged, nothing as staged.
|
||||
run_paths(r, &["reset", "-q", "HEAD"], &rels)
|
||||
}
|
||||
|
||||
/// Fills `tmp`, a copy of the index, with what a commit of `options` holds: `exclude`
|
||||
/// goes back to HEAD, `stage` takes its working-tree content and `partial` only the
|
||||
/// chosen hunks. Returns the root-relative paths of `stage` and `partial`.
|
||||
fn select_into(
|
||||
r: &Repo,
|
||||
tmp: &TempIndex,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
let index = r.git_dir.join("index");
|
||||
if index.exists() {
|
||||
std::fs::copy(&index, &tmp.0)?;
|
||||
@@ -459,10 +480,120 @@ fn commit_selected(
|
||||
write_index_blob(r, &rel, &bytes, &env)?;
|
||||
rels.push(rel);
|
||||
}
|
||||
cli::run_env(r.root(), commit_args, stdin, &env)?;
|
||||
// The user's index still holds the partial files' previous entries: align them with
|
||||
// the commit so the remaining changes show as unstaged, nothing as staged.
|
||||
run_paths(r, &["reset", "-q", "HEAD"], &rels)
|
||||
Ok(rels)
|
||||
}
|
||||
|
||||
/// The changes a commit of `options` would make, as `git diff` reports them (renames
|
||||
/// detected), from HEAD (an empty tree without one). Nothing is committed and the
|
||||
/// working tree and the index are left alone: a selection is made in a copy of the
|
||||
/// index. Patches stop growing at `max_bytes` in all.
|
||||
pub(crate) fn pending_changes(
|
||||
r: &Repo,
|
||||
options: &CommitOptions,
|
||||
max_bytes: usize,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<Vec<PendingChange>> {
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tmp = TempIndex(r.git_dir.join(format!("openidea-pending-index-{}-{n}", std::process::id())));
|
||||
let env_tmp = [("GIT_INDEX_FILE", tmp.0.as_os_str())];
|
||||
let env: &[(&str, &std::ffi::OsStr)] = if selects(options) {
|
||||
select_into(r, &tmp, options, encoding)?;
|
||||
&env_tmp
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
// `--cached` compares with HEAD, or with an empty tree on an unborn branch.
|
||||
let base = ["diff", "--cached", "-M", "--no-ext-diff", "--no-textconv", "--no-color", "--no-relative"];
|
||||
let names = cli::run_env(r.root(), &[&base[..], &["--name-status", "-z"]].concat(), None, env)?;
|
||||
let patch = cli::run_env(
|
||||
r.root(),
|
||||
&[&base[..], &["--patch", "--src-prefix=a/", "--dst-prefix=b/", "--full-index"]].concat(),
|
||||
None,
|
||||
env,
|
||||
)?;
|
||||
let mut changes = parse_name_status(&names.stdout, r.root());
|
||||
let sections = split_patch(&patch.stdout);
|
||||
let mut budget = max_bytes;
|
||||
for (change, section) in changes.iter_mut().zip(sections) {
|
||||
if patch_is_binary(section) {
|
||||
continue;
|
||||
}
|
||||
let charset = encoding(&change.path).unwrap_or_else(|_| encoding_rs::UTF_8.into());
|
||||
let text = crate::text_encoding::decode_raw(section, charset);
|
||||
if text.len() <= budget {
|
||||
budget -= text.len();
|
||||
change.diff = Some(text.into_owned());
|
||||
} else {
|
||||
let mut cut = budget.min(text.len());
|
||||
// Whole lines only, at a character boundary.
|
||||
while cut > 0 && !text.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
let kept = text[..cut].rfind('\n').map_or(0, |i| i + 1);
|
||||
budget -= kept;
|
||||
change.diff = Some(text[..kept].to_owned());
|
||||
change.truncated = true;
|
||||
}
|
||||
}
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// A file a pending commit changes ([`Repo::pending_changes`]).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PendingChange {
|
||||
pub path: PathBuf,
|
||||
/// The path before a rename or copy.
|
||||
pub old_path: Option<PathBuf>,
|
||||
/// `added`, `modified`, `deleted`, `renamed`, `copied` or `type-changed`.
|
||||
pub status: &'static str,
|
||||
/// Its section of the unified diff; `None` for binary content.
|
||||
pub diff: Option<String>,
|
||||
/// `diff` was cut at the size limit.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
/// `git diff --name-status -z`: `M\0path\0`, `R100\0old\0new\0`.
|
||||
fn parse_name_status(out: &[u8], root: &Path) -> Vec<PendingChange> {
|
||||
let mut fields = out.split(|b| *b == 0).filter(|f| !f.is_empty());
|
||||
let path = |f: &[u8]| root.join(String::from_utf8_lossy(f).as_ref());
|
||||
let mut changes = Vec::new();
|
||||
while let Some(code) = fields.next() {
|
||||
let status = match code.first() {
|
||||
Some(b'A') => "added",
|
||||
Some(b'D') => "deleted",
|
||||
Some(b'R') => "renamed",
|
||||
Some(b'C') => "copied",
|
||||
Some(b'T') => "type-changed",
|
||||
_ => "modified",
|
||||
};
|
||||
let two = matches!(code.first(), Some(b'R' | b'C'));
|
||||
let first = fields.next().map(path);
|
||||
let (old_path, path) = if two { (first, fields.next().map(path)) } else { (None, first) };
|
||||
let Some(path) = path else { break };
|
||||
changes.push(PendingChange { path, old_path, status, diff: None, truncated: false });
|
||||
}
|
||||
changes
|
||||
}
|
||||
|
||||
/// The per-file sections of a patch, each starting at its `diff --git` line.
|
||||
fn split_patch(patch: &[u8]) -> Vec<&[u8]> {
|
||||
let mut starts: Vec<usize> = Vec::new();
|
||||
let mut at = 0;
|
||||
for line in patch.split_inclusive(|b| *b == b'\n') {
|
||||
if line.starts_with(b"diff --git ") {
|
||||
starts.push(at);
|
||||
}
|
||||
at += line.len();
|
||||
}
|
||||
starts.iter().enumerate().map(|(i, &s)| &patch[s..starts.get(i + 1).copied().unwrap_or(patch.len())]).collect()
|
||||
}
|
||||
|
||||
fn patch_is_binary(section: &[u8]) -> bool {
|
||||
section
|
||||
.split(|b| *b == b'\n')
|
||||
.take_while(|l| !l.starts_with(b"@@"))
|
||||
.any(|l| l.starts_with(b"Binary files ") || l.starts_with(b"GIT binary patch"))
|
||||
}
|
||||
|
||||
pub(crate) fn checkout(r: &Repo, branch: &str, create: bool) -> IdeResult<()> {
|
||||
|
||||
@@ -1203,6 +1203,71 @@ fn partial_commit_of_chosen_hunks_leaves_worktree_and_index_alone() {
|
||||
assert_eq!(fx.read("f.txt"), "Z\nb\nc\nd\ne\nf\ng\nh\ni\nJ\nk\nl\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_changes_show_what_the_commit_would_hold_without_touching_the_index() {
|
||||
let fx = Fixture::new();
|
||||
let base = "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n";
|
||||
fx.write("f.txt", base);
|
||||
fx.write("staged.txt", "s\n");
|
||||
fx.write("old.txt", "moved\nfile\nwith\nlines\n");
|
||||
fx.write("bin.dat", "\0\x01");
|
||||
fx.commit_all("base");
|
||||
let repo = fx.repo();
|
||||
let utf8 = |_: &Path| -> ide_core::IdeResult<&encoding_rs::Encoding> { Ok(encoding_rs::UTF_8) };
|
||||
|
||||
let edited = "A\nb\nc\nd\ne\nf\ng\nh\ni\nJ\n";
|
||||
fx.write("f.txt", edited);
|
||||
fx.write("staged.txt", "s2\n");
|
||||
fx.git(&["add", "staged.txt"]);
|
||||
fx.git(&["mv", "old.txt", "new.txt"]);
|
||||
fx.write("bin.dat", "\0\x02");
|
||||
fx.write("added.txt", "new\n");
|
||||
let index_before = std::fs::read(fx.dir.join(".git/index")).unwrap();
|
||||
|
||||
// The index as it is: the staged file and the rename.
|
||||
let plain = repo.pending_changes(&CommitOptions::default(), 1 << 20, &utf8).unwrap();
|
||||
let summary: Vec<_> = plain
|
||||
.iter()
|
||||
.map(|c| (c.status, c.path.strip_prefix(&fx.dir).unwrap().to_string_lossy().into_owned()))
|
||||
.collect();
|
||||
assert_eq!(summary, [("renamed", "new.txt".to_owned()), ("modified", "staged.txt".to_owned())]);
|
||||
assert_eq!(plain[0].old_path.as_deref(), Some(fx.path("old.txt").as_path()));
|
||||
assert!(plain[1].diff.as_deref().unwrap().contains("-s\n+s2\n"), "{:?}", plain[1].diff);
|
||||
|
||||
// A selection: the first hunk of f.txt, the binary and the new file, staged.txt left out.
|
||||
let hunks = diff_hunks(base, edited);
|
||||
let options = CommitOptions {
|
||||
partial: vec![PartialCommitFile { path: fx.path("f.txt").into(), hunks: vec![range(&hunks[0])] }],
|
||||
stage: vec![fx.path("bin.dat").into(), fx.path("added.txt").into()],
|
||||
exclude: vec![fx.path("staged.txt").into()],
|
||||
..Default::default()
|
||||
};
|
||||
let picked = repo.pending_changes(&options, 1 << 20, &utf8).unwrap();
|
||||
let by_name = |name: &str| picked.iter().find(|c| c.path == fx.path(name)).unwrap_or_else(|| panic!("{name}"));
|
||||
assert!(picked.iter().all(|c| c.path != fx.path("staged.txt")));
|
||||
assert_eq!(by_name("added.txt").status, "added");
|
||||
assert_eq!(by_name("bin.dat").diff, None);
|
||||
let f = by_name("f.txt").diff.clone().unwrap();
|
||||
assert!(f.starts_with("diff --git a/f.txt b/f.txt\n"), "{f}");
|
||||
assert!(f.contains("-a\n+A\n") && !f.contains("+J"), "{f}");
|
||||
|
||||
// Nothing changed: the index, the working tree, no temporary index left behind.
|
||||
assert_eq!(std::fs::read(fx.dir.join(".git/index")).unwrap(), index_before);
|
||||
assert_eq!(fx.read("f.txt"), edited);
|
||||
let leftovers: Vec<_> = std::fs::read_dir(fx.dir.join(".git"))
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_name().to_string_lossy().starts_with("openidea-"))
|
||||
.collect();
|
||||
assert!(leftovers.is_empty(), "{leftovers:?}");
|
||||
|
||||
// The size limit cuts at a line and says so.
|
||||
let cut = repo.pending_changes(&options, 40, &utf8).unwrap();
|
||||
let total: usize = cut.iter().filter_map(|c| c.diff.as_ref()).map(String::len).sum();
|
||||
assert!(total <= 40 && cut.iter().any(|c| c.truncated));
|
||||
assert!(cut.iter().filter_map(|c| c.diff.as_ref()).all(|d| d.is_empty() || d.ends_with('\n')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_staging_with_built_patch() {
|
||||
let fx = Fixture::new();
|
||||
|
||||
@@ -22,8 +22,8 @@ tracing.workspace = true
|
||||
tempfile.workspace = true
|
||||
globset.workspace = true
|
||||
uuid.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
zip.workspace = true
|
||||
@@ -316,6 +316,48 @@ impl SettingsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// A JetBrains IDE's File › Manage IDE Settings › Export Settings… archive: every
|
||||
/// entry name, and the text of the entries OpenIDEA maps (the frontend does the mapping).
|
||||
#[derive(serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JetBrainsSettings {
|
||||
pub entries: Vec<String>,
|
||||
/// Entry name → text, for `colors/*.icls` and the `options/*.xml` files OpenIDEA reads.
|
||||
pub files: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
const JETBRAINS_MARKER: &str = "IntelliJ IDEA Global Settings";
|
||||
const JETBRAINS_OPTIONS: [&str; 4] =
|
||||
["options/editor-font.xml", "options/colors.scheme.xml", "options/editor.xml", "options/ide.general.xml"];
|
||||
const JETBRAINS_MAX_TEXT: u64 = 4 << 20;
|
||||
|
||||
pub fn read_jetbrains_settings(path: &Path) -> IdeResult<JetBrainsSettings> {
|
||||
use std::io::Read;
|
||||
let not_jetbrains = || IdeError::invalid("not a JetBrains settings archive");
|
||||
let mut archive = zip::ZipArchive::new(std::fs::File::open(path)?).map_err(|_| not_jetbrains())?;
|
||||
let mut entries = Vec::with_capacity(archive.len());
|
||||
let mut files = BTreeMap::new();
|
||||
for i in 0..archive.len() {
|
||||
let entry = archive.by_index(i).map_err(|_| not_jetbrains())?;
|
||||
let name = entry.name().map_err(|_| not_jetbrains())?.into_owned();
|
||||
let wanted = JETBRAINS_OPTIONS.contains(&name.as_str())
|
||||
|| name.strip_prefix("colors/").is_some_and(|n| !n.contains('/') && n.ends_with(".icls"));
|
||||
if wanted {
|
||||
let mut text = String::new();
|
||||
entry
|
||||
.take(JETBRAINS_MAX_TEXT)
|
||||
.read_to_string(&mut text)
|
||||
.map_err(|e| IdeError::invalid(format!("{name}: {e}")))?;
|
||||
files.insert(name.clone(), text);
|
||||
}
|
||||
entries.push(name);
|
||||
}
|
||||
if !entries.iter().any(|e| e == JETBRAINS_MARKER) {
|
||||
return Err(not_jetbrains());
|
||||
}
|
||||
Ok(JetBrainsSettings { entries, files })
|
||||
}
|
||||
|
||||
const BUNDLE_FORMAT: &str = "openidea-settings";
|
||||
const BUNDLE_VERSION: u32 = 1;
|
||||
|
||||
@@ -546,6 +588,38 @@ mod tests {
|
||||
assert_eq!(target.effective().editor.font_size, 17.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_jetbrains_settings_archives() {
|
||||
use std::io::Write;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let write = |name: &str, entries: &[(&str, &str)]| {
|
||||
let path = tmp.path().join(name);
|
||||
let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());
|
||||
for (entry, text) in entries {
|
||||
zip.start_file(*entry, zip::write::SimpleFileOptions::default()).unwrap();
|
||||
zip.write_all(text.as_bytes()).unwrap();
|
||||
}
|
||||
zip.finish().unwrap();
|
||||
path
|
||||
};
|
||||
let ok = write(
|
||||
"settings.zip",
|
||||
&[
|
||||
(JETBRAINS_MARKER, ""),
|
||||
("colors/_@user_Darcula.icls", "<scheme name=\"_@user_Darcula\"/>"),
|
||||
("options/editor-font.xml", "<application/>"),
|
||||
("options/vcs.xml", "<application/>"),
|
||||
("keymaps/macOS.xml", "<keymap/>"),
|
||||
],
|
||||
);
|
||||
let read = read_jetbrains_settings(&ok).unwrap();
|
||||
assert_eq!(read.entries.len(), 5);
|
||||
assert_eq!(read.files.keys().collect::<Vec<_>>(), ["colors/_@user_Darcula.icls", "options/editor-font.xml"]);
|
||||
assert!(read_jetbrains_settings(&write("other.zip", &[("options/editor.xml", "")])).is_err());
|
||||
std::fs::write(tmp.path().join("plain.zip"), "not a zip").unwrap();
|
||||
assert!(read_jetbrains_settings(&tmp.path().join("plain.zip")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn main_toolbar_can_be_set_and_reset() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -13,6 +13,35 @@ test("opens a file from the project tree and types into it", async ({ workbench:
|
||||
await expect(editor.locator(".cm-content")).not.toContainText("// typed by e2e");
|
||||
});
|
||||
|
||||
test("Delete edits text after the focused Project tree is remounted", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "README.md");
|
||||
const content = editor.locator(".cm-content");
|
||||
await content.press("Meta+End");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.insertText("delete probe abc");
|
||||
const line = editor.locator(".cm-line", { hasText: "delete probe" });
|
||||
await expect(line).toHaveText("delete probe abc");
|
||||
|
||||
const tree = page.getByRole("tree", { name: "Project" });
|
||||
await tree.focus();
|
||||
await page.evaluate(async () => {
|
||||
const keymapModule = "/src/platform/keymap.ts";
|
||||
const layoutModule = "/src/workbench/toolwindows/store.ts";
|
||||
const { applyKeymapSettings } = await import(/* @vite-ignore */ keymapModule);
|
||||
const { moveToolWindow } = await import(/* @vite-ignore */ layoutModule);
|
||||
applyKeymapSettings("intellij-windows", {});
|
||||
moveToolWindow("project", "right");
|
||||
});
|
||||
await expect(page.locator('[data-toolwindow="project"]')).toBeVisible();
|
||||
await expect(tree).not.toBeFocused();
|
||||
|
||||
await content.press("ArrowLeft");
|
||||
await page.keyboard.press("Delete");
|
||||
await expect(line).toHaveText("delete probe ab");
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(line).toHaveText("delete probe a");
|
||||
});
|
||||
|
||||
test("⌘F opens the find bar and highlights matches; Escape returns to the editor", async ({ workbench: page }) => {
|
||||
const editor = await openFromTree(page, "build.gradle.kts");
|
||||
await editor.locator(".cm-content").click();
|
||||
|
||||
+5
-1
@@ -92,8 +92,12 @@ test("Find in Files streams matches by file and opens the chosen one at its line
|
||||
await dialog.getByRole("textbox", { name: "Text to find" }).fill("OrderService");
|
||||
const matches = dialog.getByRole("listbox", { name: "Matches" });
|
||||
await expect(matches.getByRole("option")).toHaveCount(3);
|
||||
// The TODO index scans the project through `search_start` too, on its own schedule.
|
||||
await expect
|
||||
.poll(async () => (await ipcCalls(page, "search_start")).at(-1)?.query)
|
||||
.poll(
|
||||
async () =>
|
||||
(await ipcCalls(page, "search_start")).filter((c) => !/\bTODO\b/.test(c.query?.pattern ?? "")).at(-1)?.query,
|
||||
)
|
||||
.toMatchObject({
|
||||
pattern: "OrderService",
|
||||
});
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import { expect, test } from "./fixtures";
|
||||
|
||||
test("Project Structure keeps edits across repeated shortcuts and applies them", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+;");
|
||||
const dialog = page.getByRole("dialog", { name: "Project Structure", exact: true });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByLabel("Compiler output", { exact: true }).fill("out/classes");
|
||||
await page.keyboard.press("Meta+;");
|
||||
await expect(dialog.getByLabel("Compiler output", { exact: true })).toHaveValue("out/classes");
|
||||
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Apply", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await page.keyboard.press("Meta+;");
|
||||
await expect(dialog.getByLabel("Compiler output", { exact: true })).toHaveValue("out/classes");
|
||||
await dialog.getByRole("button", { name: "Modules", exact: true }).click();
|
||||
await dialog.getByRole("tab", { name: "Dependencies", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "Add Library", exact: true })).toBeDisabled();
|
||||
await dialog.getByRole("button", { name: "Artifacts", exact: true }).click();
|
||||
await expect(dialog.getByRole("button", { name: "JAR", exact: true })).toBeDisabled();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).toBeHidden();
|
||||
});
|
||||
|
||||
test("⌘, opens Settings with the page tree; Escape closes", async ({ workbench: page }) => {
|
||||
await page.keyboard.press("Meta+,");
|
||||
const dialog = page.getByRole("dialog", { name: "Settings" });
|
||||
|
||||
@@ -78,7 +78,13 @@ changes; a fingerprint of working copies with unsaved changes covers edits befor
|
||||
they are saved. Opening or closing an editor, reconciling, building, and non-Java
|
||||
resource changes (configuration files are parsed by the client; metadata files are
|
||||
checked by timestamp) keep it. This is **snapshot invalidation**, not a fully
|
||||
incremental Java semantic index.
|
||||
incremental Java semantic index. A rebuild reuses each dependency JAR's metadata
|
||||
associations (up to 100,000 in all) while the JAR's timestamp and size and the project's
|
||||
classpath are unchanged: resolving a Spring Boot JAR's thousands of properties to library
|
||||
members takes seconds, and a source change cannot affect them.
|
||||
|
||||
Find Usages shows the Java usages as soon as they are found, then adds the configuration
|
||||
ones when a rebuild of the snapshot takes longer.
|
||||
|
||||
In YAML/properties, ⌘/Ctrl-hover underlines the whole key, hyphens and dots
|
||||
included, and doesn't open the documentation popup. Hover and Go to Definition share
|
||||
|
||||
@@ -2,7 +2,7 @@ Manifest-Version: 1.0
|
||||
Bundle-ManifestVersion: 2
|
||||
Bundle-Name: OpenIDEA Java usages
|
||||
Bundle-SymbolicName: ai.openidea.usages;singleton:=true
|
||||
Bundle-Version: 1.7.0
|
||||
Bundle-Version: 1.8.0
|
||||
Bundle-RequiredExecutionEnvironment: JavaSE-21
|
||||
Require-Bundle: org.eclipse.jdt.ls.core,org.eclipse.jdt.core,
|
||||
org.eclipse.jdt.core.manipulation,org.eclipse.core.runtime,
|
||||
|
||||
@@ -69,7 +69,7 @@ is not a claim of full IntelliJ feature parity.
|
||||
## Rebuild and delivery
|
||||
|
||||
The checked-in jar is embedded by ide-sdk `include_bytes!`, installed as
|
||||
`java-usages-1.7.0.jar`, and added alongside existing initialization bundles.
|
||||
`java-usages-1.8.0.jar`, and added alongside existing initialization bundles.
|
||||
Users need no compiler, Maven or Python. Manifest/install version must change
|
||||
when replacing the bundle because OSGi caches bundles.
|
||||
|
||||
@@ -103,6 +103,10 @@ Version 1.7.0 adds the `"sources"` scope to `openidea.java.usages`, searches eac
|
||||
file's decompiled source on its own and returns the line (`text`) of each usage in a library
|
||||
class; its reproducible SHA-256 is
|
||||
`56d35619d5be937b028ac143ba748365a66a74c803bc112e4788a7dce98b44fc`.
|
||||
Version 1.8.0 keeps each dependency JAR's configuration metadata across rebuilds of the
|
||||
configuration index (until the JAR or the project's classpath changes), so a source change no
|
||||
longer resolves every Spring Boot property again; its reproducible SHA-256 is
|
||||
`728ab33b5e01fa5e496818f25ddbc487458a92339026899b0e22abda5adfd67b`.
|
||||
|
||||
```sh
|
||||
export PATH=/path/to/jdk25/bin:$PATH
|
||||
|
||||
Binary file not shown.
@@ -41,7 +41,41 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
* own stamps), do not change the index either. */
|
||||
private static final int IGNORED_FLAGS = IJavaElementDelta.F_CHILDREN | IJavaElementDelta.F_PRIMARY_WORKING_COPY
|
||||
| IJavaElementDelta.F_AST_AFFECTED | IJavaElementDelta.F_FINE_GRAINED;
|
||||
static { JavaCore.addElementChangedListener(event -> { if (affects(event.getDelta())) REVISION.incrementAndGet(); }, ElementChangedEvent.POST_CHANGE); }
|
||||
/** Entries read from each library's Spring metadata, by project and archive. They depend
|
||||
* on the archive and the project's classpath only, so a source change rebuilds the index
|
||||
* without reading every dependency's metadata again: resolving its thousands of properties
|
||||
* to library members takes seconds. A changed archive stamp or classpath drops them. */
|
||||
private static final Map<String, Archive> ARCHIVES = new HashMap<>();
|
||||
private record Archive(FileStamp stamp, List<Entry> entries) {}
|
||||
static {
|
||||
JavaCore.addElementChangedListener(event -> {
|
||||
if (affects(event.getDelta())) REVISION.incrementAndGet();
|
||||
if (classpathChanged(event.getDelta())) synchronized (ARCHIVES) { ARCHIVES.clear(); }
|
||||
}, ElementChangedEvent.POST_CHANGE);
|
||||
}
|
||||
/** A project or package fragment root added, removed or reconfigured (not a change of
|
||||
* the sources or resources inside it). */
|
||||
static boolean classpathChanged(IJavaElementDelta delta) {
|
||||
if (delta.getElement().getElementType() > IJavaElement.PACKAGE_FRAGMENT_ROOT) return false;
|
||||
int flags = delta.getFlags() & ~(IGNORED_FLAGS | IJavaElementDelta.F_CONTENT);
|
||||
if (delta.getKind() != IJavaElementDelta.CHANGED || flags != 0) return true;
|
||||
for (IJavaElementDelta child : delta.getAffectedChildren()) if (classpathChanged(child)) return true;
|
||||
return false;
|
||||
}
|
||||
private static List<Entry> archive(String key, FileStamp stamp) {
|
||||
synchronized (ARCHIVES) {
|
||||
Archive known = ARCHIVES.get(key);
|
||||
return known != null && known.stamp.equals(stamp) ? known.entries : null;
|
||||
}
|
||||
}
|
||||
private static void remember(String key, FileStamp stamp, List<Entry> entries) {
|
||||
synchronized (ARCHIVES) {
|
||||
ARCHIVES.remove(key);
|
||||
if (ARCHIVES.values().stream().mapToInt(known -> known.entries.size()).sum() + entries.size() > MAX_ENTRIES)
|
||||
ARCHIVES.clear();
|
||||
if (entries.size() <= MAX_ENTRIES) ARCHIVES.put(key, new Archive(stamp, entries));
|
||||
}
|
||||
}
|
||||
static boolean affects(IJavaElementDelta delta) {
|
||||
int flags = delta.getFlags() & ~IGNORED_FLAGS;
|
||||
if (delta.getElement().getElementType() < IJavaElement.COMPILATION_UNIT) flags &= ~IJavaElementDelta.F_CONTENT;
|
||||
@@ -465,8 +499,18 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
Path path = root.getPath().toFile().toPath();
|
||||
if (!root.isExternal() && root.getResource() != null && root.getResource().getLocation() != null)
|
||||
path = root.getResource().getLocation().toFile().toPath();
|
||||
files.put(path, stamp(path));
|
||||
FileStamp stamp = stamp(path);
|
||||
files.put(path, stamp);
|
||||
if (!Files.isRegularFile(path)) return;
|
||||
String key = project.getHandleIdentifier() + '\n' + path;
|
||||
List<Entry> known = archive(key, stamp);
|
||||
if (known != null && entries.size() + known.size() <= MAX_ENTRIES) {
|
||||
entries.addAll(known);
|
||||
return;
|
||||
}
|
||||
int start = entries.size();
|
||||
boolean before = incomplete;
|
||||
incomplete = false;
|
||||
try (ZipFile zip = new ZipFile(path.toFile())) {
|
||||
for (String filename : List.of("spring-configuration-metadata.json", "additional-spring-configuration-metadata.json")) {
|
||||
var entry = zip.getEntry("META-INF/" + filename);
|
||||
@@ -481,6 +525,9 @@ public final class ConfigurationCommand implements IDelegateCommandHandler {
|
||||
metadataObject(metadata, project);
|
||||
}
|
||||
} catch (Exception e) { if (e instanceof OperationCanceledException) throw (OperationCanceledException)e; incomplete = true; }
|
||||
// Only a complete reading is reused; an incomplete one is read again next time.
|
||||
if (!incomplete) remember(key, stamp, List.copyOf(entries.subList(start, entries.size())));
|
||||
incomplete |= before;
|
||||
}
|
||||
void metadataObject(JsonObject metadata, IJavaProject project) throws JavaModelException {
|
||||
JsonArray groups = metadata.has("groups") ? metadata.getAsJsonArray("groups") : new JsonArray();
|
||||
|
||||
@@ -244,6 +244,14 @@ try:
|
||||
while not query('added.size')['locations']:
|
||||
assert time.monotonic() < deadline, 'a new source on disk did not reach the index'
|
||||
time.sleep(0.2)
|
||||
# That rebuild reused the library's metadata; a changed archive is read again.
|
||||
assert query('vendor.port')['locations'], 'a source rebuild lost the library metadata'
|
||||
metadata['properties'].append({'name': 'vendor.speed', 'type': 'java.lang.Integer', 'sourceType': 'vendor.LibrarySettings'})
|
||||
with zipfile.ZipFile(library, 'w') as z:
|
||||
z.write(classes / 'vendor/LibrarySettings.class', 'vendor/LibrarySettings.class')
|
||||
z.writestr('META-INF/spring-configuration-metadata.json', json.dumps(metadata))
|
||||
assert query('vendor.speed')['locations'], 'a changed library archive kept its old metadata'
|
||||
assert query('vendor.port')['locations']
|
||||
# Unsaved changes must replace old keys, and closing must restore disk keys.
|
||||
changed = source.replace('prefix="app"', 'prefix="edited"').replace('${app.time-out:30}', '${edited.timeout:30}')
|
||||
send({'jsonrpc': '2.0', 'method': 'textDocument/didChange', 'params': {'textDocument': {'uri': file.as_uri(), 'version': 2}, 'contentChanges': [{'text': changed}]}})
|
||||
|
||||
+162
-39
@@ -34,7 +34,7 @@ plugins/
|
||||
├─ regex-tester/ # html view talking to its code, settings, the editor's selection
|
||||
└─ license-header/ # file-opened events, quick fix, project scan in a task
|
||||
sdk/rust/openidea-plugin/ # the Rust SDK (host API, Plugin trait, mock host)
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover
|
||||
crates/ide-plugin/tests/fixtures/ # test plugins: hello, jobs, language-toy, misbehave, semantic-tokens, hover, commit, inlay-hints
|
||||
```
|
||||
|
||||
The plugin API is defined in [`wit/`](../wit/) (`openidea:[email protected]`, world
|
||||
@@ -137,23 +137,24 @@ work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.
|
||||
|
||||
### Contribution points
|
||||
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| Point | Declaration | Code needed |
|
||||
| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
|
||||
| `commands` | `id`, `title`, `category?`, `when?` | to run them |
|
||||
| `themes` | `path` (theme JSON), `editor-scheme?` | no |
|
||||
| `iconThemes` | `id`, `label`, `path` (icon theme JSON, see §5) | no |
|
||||
| `languages` | `path` (`languages/<id>.toml` format, see §6) | no |
|
||||
| `runDetectors` | `id`, `name`, `files`, `command`, `args?`, `test?` (§6.2) | no |
|
||||
| `providers` | `kinds`, `languages` (editor providers, see _Providers_) | yes |
|
||||
| `secrets` | `name`, `label`, `description?` (see _Network_) | to use them |
|
||||
| `menus` | `menu` (`editor.context`, `projectView.context`, `editorTab.context`, `viewItem.context`, `tools`, `commit.message`), `view?`, `command`, `when?` | no |
|
||||
| `keymaps` | `command`, `key`, `mac?` (`Ctrl+Alt+C`; a chord: `Meta+K Meta+C`) | no |
|
||||
| `statusBar` | `id`, `text`, `tooltip?`, `command?`, `align?`, `priority?` | no |
|
||||
| `settings` | `schema` (JSON file), `title?` — one table, see _Settings_ | no |
|
||||
| `views` | `id`, `title`, `icon?` (an icon name or a package SVG), `location` (`left`, `right`, `bottom`, `services`), `web?` (_Web views_) | without `web` |
|
||||
| `customEditors` | `id`, `title`, `patterns` (file names), `priority` (`default`, `option`), `layout?`, `web?` | without `web` |
|
||||
| `fileIcons` | `id`, `patterns` (file names), `icon` (SVG), `iconDark?` — whatever icon theme is in use | no |
|
||||
| `fileTemplates` | `id`, `name`, `extension`, `path` (the new file's content; template variables work) — New menu | no |
|
||||
| `commitChecks` | `id`, `title` — a before-commit check in Commit Options (see _Commits_) | yes |
|
||||
|
||||
A `viewItem.context` menu needs `view`, the id of one of the plugin's own
|
||||
views. It adds the commands to the right-click menu of the items in that
|
||||
@@ -174,6 +175,25 @@ command = "kube.logs"
|
||||
when = "viewItemKind == pod"
|
||||
```
|
||||
|
||||
A `commit.message` menu puts the command, with the plugin's icon, in the
|
||||
toolbar above the commit message of the Commit tool window and the Commit
|
||||
dialog (see _Commits_). It is new in API 1.3 (`engines.openidea = "^1.3"`).
|
||||
|
||||
In a context menu (`editor.context`, `projectView.context`, `editorTab.context`,
|
||||
`viewItem.context`), entries can name a `submenu`: the plugin's entries of that
|
||||
menu with the same `submenu` are shown under it (`Arthas` ▸ _Watch_ / _Trace_),
|
||||
in declaration order; a submenu with one visible entry shows it as a plain
|
||||
entry, and entries without `submenu` stay where they are. The label goes
|
||||
through the plugin's translations. It is new in API 1.3
|
||||
(`engines.openidea = "^1.3"`):
|
||||
|
||||
```toml
|
||||
[[contributes.menus]]
|
||||
menu = "editor.context"
|
||||
command = "arthas.watch"
|
||||
submenu = "Arthas"
|
||||
```
|
||||
|
||||
Declared contributions are live as soon as the plugin is enabled — commands
|
||||
show in Find Action, menus and the status bar, shortcuts work, themes and icon
|
||||
themes can be applied, settings pages are there — **before** any plugin code
|
||||
@@ -227,20 +247,20 @@ installing (or when a new version asks for more). Low-risk capabilities are
|
||||
listed; high-risk ones are called out as equivalent to running code on the
|
||||
machine. Users can revoke optional capabilities in Settings › Plugins.
|
||||
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ----------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes` |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only) |
|
||||
| `storage` | low | `host::storage` (1 MiB per plugin) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
| Capability | Risk | Unlocks today |
|
||||
| ------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `project.read` | low | `Project::root`, `read_file`, `find_files`, `host::projects`, `publish_diagnostics` |
|
||||
| `editor.read` | low | `TextEditor::document`, `selections` |
|
||||
| `editor.write` | medium | `TextEditor::apply_edits` |
|
||||
| `vcs.read` | low | `Project::vcs_status` (branch, upstream, ahead/behind, changes), `vcs_remotes`, `host::pending_commit`, before-commit checks |
|
||||
| `vcs.write` | medium | `Project::vcs_fetch` (trusted projects only), `host::set_commit_message` |
|
||||
| `storage` | low | `host::storage` (64 MiB per plugin, counting UTF-8 bytes of keys and values) |
|
||||
| `events` | low | file opened/saved, editor focused, repository changed events |
|
||||
| `events.input` | medium | `action-performed` events (which actions you run, and how) |
|
||||
| `process:<program>` | high | `Project::run_process`, language servers, debug adapters, runs |
|
||||
| `net:<host>` | medium | `host::fetch` to that host (`*.example.com`: its subdomains) |
|
||||
| `secrets` | medium | `{{secret:<name>}}` in request headers, `host::secret_is_set` |
|
||||
| `clipboard` | medium | `host::copy_to_clipboard` and the clipboard API in the plugin's web views |
|
||||
|
||||
`process:` capabilities are also derived from what the contributions start: a
|
||||
language's `language-servers` and `debugger` commands and a run detector's
|
||||
@@ -346,9 +366,11 @@ then run `openidea-plugin check`.
|
||||
| `Plugin::activate` / `deactivate` | export `lifecycle` | Per instance: after a crash or idle reclaim a new one is activated. |
|
||||
| `TextEditor::document()` → `{version, text}` | `editor.text-editor.document` | `selections()` are those when the command ran (0-based lines, UTF-16 columns). |
|
||||
| `TextEditor::apply_edits(version, edits)` | `editor.text-editor.apply-edits` | One undoable change, only if the document is still at `version` — else `Error::Stale` and nothing changes. `transform_selections` retries once. |
|
||||
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win. |
|
||||
| `Project::root()`, `read_file(path)` | `workspace.project` | Absolute or project-relative; `..`/symlinks escaping the root are rejected; UTF-8 only, ≤ 16 MiB; unsaved editor contents win; a missing path is `Error::NotFound`. |
|
||||
| `Project::exists(path)`, `stat(path)` | `workspace.project` | `exists` covers files and directories, hidden and ignored included, without reading content; `stat` gives kind, size and modified time (`Error::NotFound` for a missing path). |
|
||||
| `host::projects()` | `workspace.projects` | Every open project (for scheduled work without a window). |
|
||||
| `Project::vcs_status()`, `vcs_remotes()`, `vcs_fetch()` | `vcs` | `vcs_remotes` returns `(name, url)` pairs; fetch runs git, so only in trusted projects (call it inside a task). |
|
||||
| `host::pending_commit()`, `host::set_commit_message(revision, text)` | `commit` | The commit being prepared in the calling window and its message; see _Commits_. |
|
||||
| `host::subscribe(kind)` | `events.subscribe` | For the session, across idle reclaim (an event activates the plugin). Or declare `on-event:<kind>`. |
|
||||
| `host::begin_task(title, cancellable)` → `Task` | `tasks.begin` | Shown with the background tasks; `report`, `is_cancelled`; the call may run 60 s instead of 5 s while it is open; dropping it ends it. |
|
||||
| `host::storage::{get, set, delete, keys}` | `storage` | Survives restarts; the instance's memory does not. Deleted on uninstall. |
|
||||
@@ -359,7 +381,9 @@ then run `openidea-plugin check`.
|
||||
| `host::open_url(url)` | `ui.open-url` | Opens an `http://` or `https://` URL in the user's default browser. |
|
||||
| `host::pick(title, items)`, `pick_many` | `ui.pick`, `ui.pick-many` | Asks the user to pick from `PickItem`s and waits: the chosen key(s), or `None` when dismissed. Only from a command or event of a window (else `invalid`); the wait isn't charged to the time limit. |
|
||||
| `host::input(prompt, value)` | `ui.input` | A one-line text box pre-filled with `value`; `None` when dismissed. Same rules as `pick`. |
|
||||
| `host::input_multiline(prompt, value)` | `ui.input-multiline` | A multi-line text box pre-filled with `value`; `None` when dismissed; Cmd/Ctrl+Enter confirms. Same rules as `input`. |
|
||||
| `host::confirm(title, body)` | `ui.confirm` | OK / Cancel; `true` on OK. Same rules as `pick`. |
|
||||
| `host::locale()` | `ui.locale` | The IDE's UI language as a BCP 47 tag (`en`, `fr`, `zh-CN`); no capability. See _Translations_. |
|
||||
| `host::log(level, msg)` | `log` | IDE log, tagged with the plugin id. `eprintln!` output is logged too and shown when the plugin crashes. |
|
||||
| `Project::find_files(glob, limit)` | `workspace.project.find-files` | Project-relative glob (`**/*Mapper.xml`); honours `.gitignore`; at most `limit` paths. |
|
||||
| `host::publish_diagnostics(path, items)` | `diagnostics.publish` | Replaces this plugin's problems for the file (editor squiggles and the Problems view); an empty list clears them. |
|
||||
@@ -395,6 +419,45 @@ a new API: an IDE refuses a kind it doesn't know with `invalid`, and a kind
|
||||
newer than the plugin's SDK arrives as `Event::Other { kind, data }` (JSON).
|
||||
The SDK's event enums are `#[non_exhaustive]`; match them with a `_` arm.
|
||||
|
||||
### Commits
|
||||
|
||||
A plugin can work on the commit the user is preparing, in the Commit tool
|
||||
window or the Commit dialog (API 1.3, `engines.openidea = "^1.3"`):
|
||||
|
||||
- **A button above the message.** `[[contributes.menus]]` with
|
||||
`menu = "commit.message"` shows one of the plugin's commands there, with the
|
||||
plugin's icon.
|
||||
- **The pending commit.** `host::pending_commit()` (`vcs.read`) returns what
|
||||
Commit would commit right now: per file its repository, path, status and a
|
||||
`git diff` of only the included changes (the checked files and changed
|
||||
blocks, against HEAD; 1 MiB in all, then `truncated`), plus the message,
|
||||
Amend and a `revision`. Unsaved editors of those files are saved first, as
|
||||
Commit does; nothing is staged or committed. `None` without a repository.
|
||||
- **The message.** `host::set_commit_message(revision, text)` (`vcs.write`)
|
||||
replaces it only while the draft is still at `revision`. If the user typed
|
||||
or changed the included changes meanwhile, it fails with `Error::Stale` and
|
||||
nothing is overwritten. The user reviews the text and commits.
|
||||
|
||||
Both work from a command or event of a window, in a trusted project; they wait
|
||||
for the window, which isn't charged to the time limit. A message generator
|
||||
calls a model with `host::fetch` and a `{{secret:…}}` key (see _Network_)
|
||||
inside a task:
|
||||
|
||||
```rust
|
||||
let draft = host::pending_commit()?.ok_or("no repository")?;
|
||||
let message = ask_the_model(&draft.changes)?;
|
||||
host::set_commit_message(draft.revision, &message)?;
|
||||
```
|
||||
|
||||
**Before-commit checks.** Each `[[contributes.commitChecks]]` (`id`, `title`)
|
||||
is listed in Commit Options, on by default. When the user commits, after the
|
||||
IDE's own checks, the IDE calls the optional `commit-checker` export
|
||||
(`register_plugin!(MyPlugin, commit_checks)`, trait `commit::CommitChecks`)
|
||||
with the check's id and the draft. Problems (`message`, `location?`) stop the
|
||||
commit and show beside the message with the check's title, until the user
|
||||
changes it or chooses Commit Anyway. A check that fails, times out or lacks
|
||||
`vcs.read` is reported as a problem too.
|
||||
|
||||
### Providers
|
||||
|
||||
Declare what the plugin provides in the editor; only plugins whose
|
||||
@@ -402,7 +465,7 @@ declaration matches the file's language are called (and loaded):
|
||||
|
||||
```toml
|
||||
[[contributes.providers]]
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover"
|
||||
kinds = ["definition", "line-markers", "completion"] # also "inline-completion", "code-actions", "semantic-tokens", "hover", "inlay-hints"
|
||||
languages = ["java", "xml"] # editor language ids; "*" for any
|
||||
```
|
||||
|
||||
@@ -417,7 +480,16 @@ can be a `snippet` (`$1`, `${1:name}`, `$0`), match on `filter_text` instead
|
||||
of its label, and bring `additional_edits` elsewhere in the document (an
|
||||
import). A code action can leave `edits` empty and set `data`: its edits are
|
||||
then computed by `resolve_code_action` when the user picks it, under the
|
||||
command budget instead of the 50 ms. Problems (`publish_diagnostics`) can name
|
||||
command budget instead of the 50 ms. A quick fix that creates, renames or
|
||||
deletes files implements `file_actions::FileActions` too
|
||||
(`register_plugin!(T, providers, file_actions)`; the `code-actions` kind
|
||||
covers both): a `FileAction` carries `operations` (`FileOperation::Create {
|
||||
path, text, overwrite }`, `Rename`, `Delete`) and `edits`, applied as one
|
||||
workspace edit, so a failure changes nothing and one undo reverses it. A path
|
||||
outside the project root disables the action; `open` shows a file once the
|
||||
action is applied. Like a code action, it can leave `operations` and `edits`
|
||||
empty and set `data`: `resolve_file_action` computes them when the user picks
|
||||
it. File actions are new in API 1.3. Problems (`publish_diagnostics`) can name
|
||||
the tool that found them (`source`) and be `unnecessary` (faded) or
|
||||
`deprecated` (struck through).
|
||||
Editor providers run under the edit-path budget: 50 ms, after which the
|
||||
@@ -477,6 +549,27 @@ edit-path budget, and shows the server's hover first, then each plugin's
|
||||
below a rule. `range`, when given, is the text the hover describes (used when
|
||||
the server names none). At most 64 KiB of Markdown per plugin.
|
||||
|
||||
#### Inlay hints
|
||||
|
||||
The `inlay-hints` kind (API 1.3) shows short labels inside the code of
|
||||
documents of the declared languages, such as a type after a call. Register
|
||||
with `register_plugin!(MyPlugin, inlay_hints)` and implement
|
||||
`openidea_plugin::inlay_hints::InlayHints`:
|
||||
|
||||
```rust
|
||||
impl InlayHints for MyPlugin {
|
||||
fn inlay_hints(&mut self, doc: &Document, range: Range) -> Vec<InlayHint> {
|
||||
vec![InlayHint::new(range.start, ": string").kind(InlayKind::Type).tooltip("returns string")]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`range` is the visible part of the document; hints outside it are dropped. A
|
||||
hint's kind (`type`, `parameter` or `other`) decides which Inlay Hints setting
|
||||
shows it. The host asks the language server and the plugins together, under
|
||||
the edit-path budget, and shows both; the editor asks again after edits and
|
||||
scrolling. Labels are cut at 256 bytes and tooltips at 1 KiB.
|
||||
|
||||
### Network and secrets
|
||||
|
||||
`host::fetch` makes an HTTPS request (plain HTTP only to `localhost`) to a host
|
||||
@@ -519,6 +612,12 @@ collapse of items, form fields, table cells, inserted and deleted rows, and
|
||||
`html` messages. A tree is a flat list whose items name their `parent`; an
|
||||
`expandable` item gets its children when it is expanded. A table gets only
|
||||
the rows it shows (`viewport`): report `total_rows` and return those.
|
||||
For a table the user can sort by clicking a header, build it with
|
||||
`Body::sortable_table(&table, sort)`: it travels as a `table` through `other`
|
||||
(so IDEs and plugins of any API version keep working), the IDE makes the
|
||||
column headers clickable and asks for a column and direction with
|
||||
`ViewEvent::sort()` — sort your rows and render again; the sort you report
|
||||
shows as an arrow in the header.
|
||||
Bodies, field types and view events added by later APIs travel as `Other {
|
||||
kind, data }`: an IDE that doesn't know a body says it needs updating, and
|
||||
leaves out a field type it doesn't know.
|
||||
@@ -583,13 +682,13 @@ optional = ["process:kubectl"]
|
||||
The items of the view's `tree` are Services nodes: `render`, `handle` and the
|
||||
toolbar work as in a tool window, the toolbar's buttons acting on the one
|
||||
selected node, and `viewItem.context` menus give the nodes a right-click menu.
|
||||
The pane beside the tree comes from the `service-provider` export: register
|
||||
`register_plugin!(MyPlugin, views, services)` and implement
|
||||
`openidea_plugin::services::Services`, which returns the selected node's tabs
|
||||
The pane beside the tree comes from the `detail-provider` export: register
|
||||
`register_plugin!(MyPlugin, views, details)` and implement
|
||||
`openidea_plugin::details::Details`, which returns the selected node's tabs
|
||||
(at most 8; asked again after `host::refresh_view("kube")`):
|
||||
|
||||
```rust
|
||||
impl Services for MyPlugin {
|
||||
impl Details for MyPlugin {
|
||||
fn detail(&mut self, view: &str, key: &str, _ctx: &Invocation) -> Result<Vec<Tab>, Error> {
|
||||
let pod = key.trim_start_matches("pod:");
|
||||
let log = ProcessLog::new("kubectl", &["logs", "-f", "--tail=1000", pod])
|
||||
@@ -652,6 +751,30 @@ sends what Graphviz reports to the plugin, which publishes it as problems. The b
|
||||
checked in (it is tens of MB): run `plugins/build.sh official/excalidraw`
|
||||
before installing the plugin from the repository.
|
||||
|
||||
### Translations
|
||||
|
||||
A package translates its own text with `locales/<tag>.json`, one file per language
|
||||
(`fr.json`, `zh-CN.json`), no manifest entry needed. A catalog maps the English source
|
||||
text to its translation, like the IDE's text catalogs:
|
||||
|
||||
```json
|
||||
{ "Count Words": "Compter les mots", "Refresh": "Actualiser" }
|
||||
```
|
||||
|
||||
The IDE applies the catalog of the selected language (the exact tag, then the language,
|
||||
then a variant such as `zh-TW` for `zh`) to the titles, categories, labels and tooltips
|
||||
of the manifest's commands, views, editors, status bar items, settings (and their
|
||||
properties), file templates, themes and secrets, and to what views show: toolbar
|
||||
buttons, tree and list items, form field labels, table column names, messages and the
|
||||
status line, and status bar items set by code. Text without an entry, and every text
|
||||
when the language has no catalog, shows as written. A catalog applies to its own plugin
|
||||
only and never changes the IDE's text. `openidea-plugin check` refuses a catalog that is
|
||||
not a JSON object of strings.
|
||||
|
||||
Text the plugin builds itself (notifications, error messages, `html` bodies, cell data)
|
||||
is not translated: read `host::locale()` when making it. It follows a change of
|
||||
language, and `MockHost::set_locale` sets it in tests.
|
||||
|
||||
### Settings
|
||||
|
||||
```toml
|
||||
|
||||
BIN
Binary file not shown.
+1
-1
@@ -1 +1 @@
|
||||
9384d8964c7c05d3df06521e0d724a932fdd17608cbcede13ec2acadfab3783c
|
||||
df2aad3aa261a7cc4059c92666d221d1599306d47049b7b97a7bba263c6f51e5
|
||||
BIN
Binary file not shown.
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user