Файли
l0ng-ai 047cac209b feat(plugins): plugin API 1.4 (#2207)
Plugin API 1.4: grants for unencrypted HTTP, local sockets, IDE commands and owned database sources; window status items, output channels, content tabs, editor selections, decorations, search, Problems and VCS reads; runs, keyed terminals with exit events, plugin run targets, dynamic run configurations and test parsers; language features, injections, snippets and menu locations; plugin MCP tools on the built-in server; four official API examples. Released 1.3 plugins keep working.
2026-11-09 08:40:40 +08:00

388 рядки
17 KiB
Rust

//! Generic document-indexer contract through a minimal real WASM fixture.
use ide_core::{Pos, Range, SymbolKind};
use ide_host::plugins::navigation::Snapshot;
use ide_host::plugins::{Consent, PluginHost};
use ide_language::SymbolTag;
use ide_plugin::{Invocation, Limits};
use std::{
path::{Path, PathBuf},
sync::Arc,
};
struct Fixture {
_tmp: tempfile::TempDir,
root: PathBuf,
host: PluginHost,
}
impl Fixture {
fn new() -> Self {
Self::with_manifest(|_| {})
}
fn with_manifest(edit: impl FnOnce(&Path)) -> Self {
let tmp = tempfile::tempdir().unwrap();
let root = dunce::canonicalize(tmp.path()).unwrap();
let host = PluginHost::new(root.join("plugins"), Arc::new(|_: Option<&str>, _| {}), Limits::default()).unwrap();
host.set_project_policy(Arc::new(|_| true));
let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("../ide-plugin/tests/fixtures/indexer");
let package = tempfile::tempdir().unwrap();
let pkg = package.path();
std::fs::create_dir_all(pkg.join("dist")).unwrap();
for file in ["plugin.toml", "dist/plugin.wasm"] {
std::fs::copy(fixture.join(file), pkg.join(file)).unwrap();
}
edit(pkg);
host.install_from_dir(pkg, &Consent::all()).unwrap();
Self { _tmp: tmp, root, host }
}
fn index(&self, path: &str, text: &str, symbols: &[SymbolTag], version: Option<u32>, scope: &str) -> PathBuf {
let path = self.root.join(path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, text).unwrap();
self.host
.index_navigation(
&self.root,
Snapshot { path: &path, version, text, symbols, scope },
Invocation { workspace_root: Some(self.root.clone()), ..Default::default() },
)
.unwrap();
path
}
}
fn method() -> SymbolTag {
SymbolTag {
name: "item".into(),
kind: SymbolKind::Method,
container: None,
range: Range::new(Pos::new(0, 4), Pos::new(0, 8)),
}
}
const SOURCE: &str = "def source:item\nref mapping:item\n";
const MAPPING: &str = "def mapping:item\nref source:item\n";
fn reference_pos() -> Pos {
Pos::new(1, 5)
}
#[test]
fn bidirectional_navigation_versions_and_incremental_deletion() {
let f = Fixture::new();
let source = f.index("src/source.idx", SOURCE, &[method()], Some(1), "app");
let mapping = f.index("resources/mapping.idx", MAPPING, &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets[0].path.0, source);
assert_eq!(result.range.end.col - result.range.start.col, 11);
let reverse = f.host.navigation_at(&f.root, &source, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(reverse.targets[0].path.0, mapping);
assert!(f.host.navigation_at(&f.root, &mapping, 2, reference_pos(), None, |_| None).is_none());
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| Some(2)).is_none());
f.host.invalidate_navigation(&f.root, Some(&[source]));
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn module_candidates_reload_disable_and_project_isolation() {
let f = Fixture::new();
let first = f.index("a/source.idx", SOURCE, &[method()], Some(1), "a");
f.index("b/source.idx", SOURCE, &[method()], Some(1), "b");
let mapping = f.index("a/mapping.idx", MAPPING, &[], Some(1), "a");
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets.len(), 1);
assert_eq!(result.targets[0].path.0, first);
let other = f.index("c/mapping.idx", MAPPING, &[], Some(1), "c");
assert_eq!(f.host.navigation_at(&f.root, &other, 1, reference_pos(), None, |_| None).unwrap().targets.len(), 2);
assert!(
f.host.navigation_at(&f.root.join("other-project"), &mapping, 1, reference_pos(), None, |_| None).is_none()
);
f.host.set_enabled("test.indexer", false).unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
f.host.set_enabled("test.indexer", true).unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
f.host.reload("test.indexer").unwrap();
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn warmed_clicks_never_read_or_parse_files() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
// Files become unreadable; the watcher invalidates separately. Resolution
// must remain purely in-memory, with no disk reads or component execution.
std::fs::remove_file(&source).unwrap();
std::fs::remove_file(&mapping).unwrap();
let calls = f.host.info_of("test.indexer").unwrap().stats.calls;
for _ in 0..10_000 {
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap().targets.len(),
1
);
}
assert_eq!(f.host.info_of("test.indexer").unwrap().stats.calls, calls, "warm navigation must not execute Wasm");
f.host.invalidate_navigation(&f.root, None);
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).is_none());
}
#[test]
fn permissions_and_paths_are_checked_before_indexing() {
let f = Fixture::new();
let external = tempfile::NamedTempFile::new().unwrap();
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: external.path(), version: None, text: "", symbols: &[], scope: "" },
Invocation::default()
)
.is_err()
);
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.navigation_extensions(&f.root).is_empty());
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &f.root.join("x.idx"), version: None, text: MAPPING, symbols: &[], scope: "" },
Invocation::default()
)
.is_err()
);
}
#[cfg(windows)]
#[test]
fn verbatim_snapshots_use_the_hosts_plain_document_identity() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
let verbatim = std::fs::canonicalize(&source).unwrap();
assert_ne!(source, verbatim);
f.host
.index_navigation(
&f.root,
Snapshot { path: &verbatim, version: Some(2), text: SOURCE, symbols: &[method()], scope: "app" },
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
)
.unwrap();
let result = f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), None, |_| None).unwrap();
assert_eq!(result.targets.len(), 1, "a different prefix must replace the same document snapshot");
assert_eq!(result.targets[0].path.0, source);
assert!(f.host.indexed_symbol_at(&f.root, &source, Pos::new(0, 4), None).is_some());
}
#[cfg(unix)]
#[test]
fn snapshot_paths_still_reject_symlink_aliases_and_escapes() {
let f = Fixture::new();
let path = f.index("source.idx", SOURCE, &[method()], Some(1), "app");
let outside = tempfile::NamedTempFile::new().unwrap();
for (target, name) in [(path.as_path(), "alias.idx"), (outside.path(), "escape.idx")] {
let alias = f.root.join(name);
std::os::unix::fs::symlink(target, &alias).unwrap();
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &alias, version: None, text: SOURCE, symbols: &[], scope: "app" },
Invocation::default(),
)
.is_err()
);
}
}
#[test]
fn indexed_refactorings_drop_graphs_after_permission_revocation_or_lost_trust() {
let f = Fixture::new();
let path = f.index("source.idx", "def item\nref item\n", &[], Some(1), "app");
let pos = Pos::new(0, 4);
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_some());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_project_policy(Arc::new(|_| true));
f.host.set_capability("test.indexer", "project.read", false).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none());
f.host.set_capability("test.indexer", "project.read", true).unwrap();
assert!(f.host.indexed_symbol_at(&f.root, &path, pos, None).is_none(), "regrant requires fresh snapshots");
}
#[test]
fn syntax_symbols_aliases_and_utf16_ranges_cross_the_wasm_boundary() {
let f = Fixture::new();
let symbol = method();
let definition = f.index("source.idx", "def item\nsymbols parent#\n", &[symbol], Some(1), "app");
f.index("child.idx", "alias child# parent#\n", &[], Some(1), "app");
let mapping = f.index("mapping.idx", "ref child#item\n", &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &mapping, 1, Pos::new(0, 6), None, |_| None).unwrap();
assert_eq!(result.targets[0].path.0, definition);
assert_eq!(result.targets[0].range, method().range);
f.index("unicode.idx", "def 😀\n", &[], Some(1), "app");
let unicode = f.index("unicode-ref.idx", "ref 😀\n", &[], Some(1), "app");
let result = f.host.navigation_at(&f.root, &unicode, 1, Pos::new(0, 5), None, |_| None).unwrap();
assert_eq!(result.range, Range::new(Pos::new(0, 4), Pos::new(0, 6)));
}
#[test]
fn two_windows_of_same_root_keep_unsaved_snapshots_isolated() {
let f = Fixture::new();
let source = f.index("source.idx", SOURCE, &[method()], Some(1), "");
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "");
for client in ["one", "two"] {
let mut symbol = method();
if client == "two" {
symbol.range = Range::new(Pos::new(0, 5), Pos::new(0, 9));
}
for (path, text, symbols) in [(&source, "symbols source:\n", vec![symbol]), (&mapping, MAPPING, vec![])] {
f.host
.index_navigation(
&f.root,
Snapshot { path, version: Some(1), text, symbols: &symbols, scope: "" },
Invocation {
command_chain: Vec::new(),
client: Some(client.into()),
workspace_root: Some(f.root.clone()),
editor: None,
selected_paths: Vec::new(),
global_settings: false,
},
)
.unwrap();
}
}
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).unwrap().targets[0]
.range
.start
.col,
4
);
assert_eq!(
f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).unwrap().targets[0]
.range
.start
.col,
5
);
f.host.invalidate_client_navigation(&f.root, Some("one"), None);
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("one"), |_| None).is_none());
assert!(f.host.navigation_at(&f.root, &mapping, 1, reference_pos(), Some("two"), |_| None).is_some());
}
#[test]
fn markers_and_disable_follow_graph_state() {
let f = Fixture::new();
let tags = vec![];
let source = f.index("source.idx", SOURCE, &tags, Some(1), "app");
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
let mapping = f.index("mapping.idx", MAPPING, &[], Some(1), "app");
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
let source_markers = entries.iter().find(|e| e.path == source).unwrap();
assert_eq!(source_markers.markers.len(), 1);
assert_eq!(source_markers.markers[0].targets[0].path.0, mapping);
let mapping_markers = entries.iter().find(|e| e.path == mapping).unwrap();
assert_eq!(mapping_markers.markers.len(), 1);
assert_eq!(mapping_markers.markers[0].targets[0].path.0, source);
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| Some(2)).is_empty());
f.host.invalidate_navigation(&f.root, Some(&[mapping]));
assert!(f.host.navigation_markers(&f.root, std::slice::from_ref(&source), None, |_| None).is_empty());
f.host.set_enabled("test.indexer", false).unwrap();
assert!(f.host.navigation_markers(&f.root, &[source], None, |_| None).is_empty());
}
#[test]
fn marker_icons_map_indexed_markers_by_longest_key_prefix() {
let f = Fixture::with_manifest(|pkg| {
let manifest = std::fs::read_to_string(pkg.join("plugin.toml")).unwrap().replace("^1.2", "^1.3");
std::fs::create_dir_all(pkg.join("icons")).unwrap();
std::fs::write(
pkg.join("icons/marker.svg"),
r#"<svg viewBox="0 0 16 16"><rect width="16" height="16"/></svg>"#,
)
.unwrap();
std::fs::write(
pkg.join("plugin.toml"),
format!(
r#"{manifest}
[[contributes.markerIcons]]
id = "mapping"
icon = "icons/marker.svg"
markers = ["mapping:"]
[[contributes.markerIcons]]
id = "mapping-item"
icon = "icons/marker.svg"
markers = ["other:", "mapping:item"]
tooltip = "Mapped item"
"#
),
)
.unwrap();
});
let source = f.index("source.idx", "def source:item\nref mapping:item\nref mapping:other\n", &[], Some(1), "app");
let mapping = f.index("mapping.idx", "def mapping:item\ndef mapping:other\nref source:item\n", &[], Some(1), "app");
let entries = f.host.navigation_markers(&f.root, &[source.clone(), mapping.clone()], None, |_| None);
let icons = |path: &PathBuf| {
let entry = entries.iter().find(|e| &e.path == path).unwrap();
assert!(entry.markers.iter().all(|m| m.plugin_id == "test.indexer"));
entry.markers.iter().map(|m| m.icon.as_deref()).collect::<Vec<_>>()
};
assert_eq!(icons(&source), [Some("mapping-item"), Some("mapping")]);
assert_eq!(icons(&mapping), [None]);
}
#[test]
fn invalid_ranges_and_oversized_documents_never_publish() {
let f = Fixture::new();
let path = f.root.join("invalid.idx");
std::fs::write(&path, "invalid key\n").unwrap();
for text in ["invalid key\n".to_owned(), "x".repeat(ide_host::plugins::navigation::MAX_DOCUMENT_BYTES + 1)] {
assert!(
f.host
.index_navigation(
&f.root,
Snapshot { path: &path, version: Some(1), text: &text, symbols: &[], scope: "" },
Invocation { workspace_root: Some(f.root.clone()), ..Default::default() },
)
.is_err()
);
}
assert!(f.host.navigation_markers(&f.root, &[path], None, |_| None).is_empty());
}
#[test]
fn usages_from_definitions_and_references_follow_aliases_module_scope_and_client() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/child.idx", "alias child# parent#\n", &[], None, "a");
let reference = f.index("a/use.idx", "ref child#item\n", &[], Some(1), "a");
let other = f.index("b/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "b");
let external = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
let unrelated = f.index("b/use.idx", "ref child#item\n", &[], Some(1), "b");
for (path, pos) in [(&definition, Pos::new(0, 4)), (&reference, Pos::new(0, 6))] {
let symbol = f.host.indexed_symbol_at(&f.root, path, pos, None).unwrap();
assert!(symbol.files.iter().any(|f| f.path == definition));
assert!(symbol.files.iter().any(|f| f.path == reference));
assert!(!symbol.files.iter().any(|f| f.path == other), "prefer same-module definitions and their uses");
assert!(symbol.files.iter().any(|f| f.path == external), "external reference resolves to this definition");
assert!(!symbol.files.iter().any(|f| f.path == unrelated), "reference resolves to its own module");
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 0), None).is_none());
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), Some("other-window")).is_none());
f.host.set_project_policy(Arc::new(|_| false));
assert!(f.host.indexed_symbol_at(&f.root, &definition, Pos::new(0, 4), None).is_none());
}
#[test]
fn cross_module_reference_and_definition_have_the_same_rename_anchors() {
let f = Fixture::new();
let definition = f.index("a/source.idx", "def item\nsymbols parent#\n", &[method()], Some(1), "a");
f.index("a/alias.idx", "alias child# parent#\n", &[], None, "a");
let external = f.index("b/use.idx", "ref item\n", &[], Some(1), "b");
let alias = f.index("c/use.idx", "ref child#item\n", &[], Some(1), "c");
for path in [&definition, &external, &alias] {
let symbol = f.host.indexed_symbol_at(&f.root, path, Pos::new(0, 4), None).unwrap();
let paths: Vec<_> = symbol.files.iter().map(|f| f.path.clone()).collect();
assert_eq!(paths, vec![definition.clone(), external.clone(), alias.clone()]);
assert_eq!(symbol.files.iter().map(|f| f.ranges.len()).sum::<usize>(), 3);
}
}