feat(lsp): language servers inside plugin-injected ranges (#2235)

* feat(lsp): language servers inside plugin-injected ranges

* chore(plugins): rebuild bundled plugins for the updated injections docs
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 11:53:07 +08:00
1 джерело b6b09eb968
коміт 54d35d8a67
58 files changed
+1493 -114

No files matched your search

+107 -20
Переглянути файл
@@ -9,6 +9,34 @@ use ide_lsp::types::*;
use ide_plugin::ProviderDocument;
use std::path::Path;
/// Select an embedded server only for point requests; formatting and rename keep the host.
async fn position_document(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<DocId> {
let lsp = host.project(window)?.lsp.clone();
lsp.refresh_injections(doc).await?;
Ok(lsp.injected_document(doc, pos))
}
async fn begin_position_request(
host: &Host,
window: &str,
request: RequestId,
doc: DocId,
pos: Pos,
) -> IdeResult<(DocId, ide_core::CancellationToken)> {
let cancel = host.begin_request(request);
let target = tokio::select! {
result = position_document(host, window, doc, pos) => result,
_ = cancel.cancelled() => Err(ide_core::IdeError::cancelled()),
};
match target {
Ok(target) => Ok((target, cancel)),
Err(e) => {
host.end_request(request);
Err(e)
}
}
}
async fn prepare_actions(
project: std::sync::Arc<crate::Project>,
doc: DocId,
@@ -41,8 +69,8 @@ pub async fn lsp_completion(
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.completion(doc, pos, trigger.clone(), cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp.completion(target, pos, trigger.clone(), cancel).await;
host.end_request(request);
// Plugin providers (`[[contributes.providers]]`) add theirs.
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Completion) else { return result };
@@ -238,14 +266,14 @@ pub async fn lsp_hover(
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Hover) else {
let result = lsp.hover(doc, pos, cancel).await;
let result = lsp.hover(target, pos, cancel).await;
host.end_request(request);
return result;
};
let (result, hovers) =
tokio::join!(lsp.hover(doc, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
tokio::join!(lsp.hover(target, pos, cancel), plugin_call(move || plugins.provide_hover(&d, pos)));
host.end_request(request);
let hovers = hovers.unwrap_or_default();
if hovers.is_empty() {
@@ -260,7 +288,8 @@ pub async fn lsp_hover(
}
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
let server = host.project(window)?.lsp.signature_help(doc, pos).await;
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.signature_help(target, pos).await;
if matches!(&server,Ok(Some(h)) if !h.signatures.is_empty()) {
return server;
}
@@ -278,7 +307,9 @@ pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos)
}
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
if kind == GotoKind::Definition
let target = position_document(host, window, doc, pos).await?;
if target == doc
&& kind == GotoKind::Definition
&& let Some(plugins) = crate::plugins::editor_plugin_host()
{
let project = host.project(window)?;
@@ -289,7 +320,7 @@ pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: Got
return Ok(found.targets);
}
}
let result = host.project(window)?.lsp.goto(doc, pos, kind).await;
let result = host.project(window)?.lsp.goto(target, pos, kind).await;
if matches!(kind, GotoKind::Implementation | GotoKind::TypeDefinition) {
let k =
if kind == GotoKind::Implementation { ProviderKind::Implementation } else { ProviderKind::TypeDefinition };
@@ -424,8 +455,46 @@ pub async fn lsp_implementations(
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
let (_, cancel) =
begin_position_request(host, window, request, doc, positions.first().copied().unwrap_or_default()).await?;
let mut groups = std::collections::BTreeMap::<DocId, Vec<(usize, Pos)>>::new();
for (index, pos) in positions.iter().copied().enumerate() {
groups.entry(lsp.injected_document(doc, pos)).or_default().push((index, pos));
}
if groups.is_empty() {
groups.insert(doc, Vec::new());
}
let results = futures::future::join_all(groups.into_iter().map(|(target, entries)| {
let (lsp, cancel) = (lsp.clone(), cancel.clone());
async move {
let positions: Vec<_> = entries.iter().map(|(_, p)| *p).collect();
(entries, lsp.implementations(target, &positions, cancel).await)
}
}))
.await;
let mut out = vec![Vec::new(); positions.len()];
let mut error = None;
let mut succeeded = false;
for (entries, result) in results {
match result {
Ok(found) => {
succeeded = true;
for ((index, _), locations) in entries.into_iter().zip(found) {
out[index] = locations;
}
}
Err(e) => {
error.get_or_insert(e);
}
}
}
let result = if cancel.is_cancelled() {
Err(ide_core::IdeError::cancelled())
} else if succeeded {
Ok(out)
} else {
Err(error.expect("at least one request"))
};
host.end_request(request);
if let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::Implementation) {
let count = positions.len();
@@ -456,9 +525,9 @@ pub async fn lsp_goto_implementation(
on_partial: super::Channel<Vec<Location>>,
) -> IdeResult<Vec<Location>> {
let lsp = host.project(window)?.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = lsp
.goto_implementation(doc, pos, cancel, &|locs| {
.goto_implementation(target, pos, cancel, &|locs| {
let _ = on_partial.send(locs);
})
.await;
@@ -505,9 +574,9 @@ pub async fn lsp_references(
) -> IdeResult<References> {
let project = host.project(window)?;
let lsp = project.lsp.clone();
let cancel = host.begin_request(request);
let (target, cancel) = begin_position_request(host, window, request, doc, pos).await?;
let result = async {
let symbol = indexed_symbol(host, window, doc, pos).await?;
let symbol = if target == doc { indexed_symbol(host, window, doc, pos).await? } else { None };
if let Some(symbol) = &symbol {
let project = project.clone();
let snapshot = symbol.clone();
@@ -517,7 +586,7 @@ pub async fn lsp_references(
}
let result = lsp
.references_streaming(
doc,
target,
pos,
include_declaration,
project_wide,
@@ -643,7 +712,8 @@ fn merge_indexed_references(mut server: References, symbol: &crate::plugins::nav
}
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
let server = host.project(window)?.lsp.document_highlights(doc, pos).await;
let target = position_document(host, window, doc, pos).await?;
let server = host.project(window)?.lsp.document_highlights(target, pos).await;
let Some((plugins, d)) = plugin_providers(host, window, doc, ProviderKind::DocumentHighlights) else {
return server;
};
@@ -674,7 +744,9 @@ pub async fn lsp_code_actions(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let result = project.lsp.code_actions(doc, range, only.clone()).await;
let target = position_document(host, window, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_actions(target, range, only.clone()).await;
let plugin_actions = match plugin_providers(host, window, doc, ProviderKind::CodeActions) {
Some((plugins, d)) => plugin_call(move || plugins.provide_code_actions(&d, range)).await.unwrap_or_default(),
None => Vec::new(),
@@ -811,8 +883,9 @@ pub async fn lsp_code_action_query(
let project = host.project(window)?;
let documents = project.documents.clone();
let version = blocking(move || documents.version(doc)).await?;
let cancel = host.begin_request(request);
let result = project.lsp.code_action_query(doc, range, only, cancel).await;
let (target, cancel) = begin_position_request(host, window, request, doc, range.start).await?;
let range = project.lsp.injected_request_range(target, range);
let result = project.lsp.code_action_query(target, range, only, cancel).await;
host.end_request(request);
let mut result = result?;
result.actions = prepare_actions(project, doc, version, result.actions).await?;
@@ -1433,7 +1506,9 @@ pub struct PluginLanguageVisuals {
pub gutter_actions: Vec<(String, ide_plugin::language_features::GutterAction)>,
}
pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> IdeResult<PluginLanguageVisuals> {
let snap = host.project(window)?.documents.snapshot(doc)?;
let project = host.project(window)?;
let snap = project.documents.snapshot(doc)?;
let generation = project.lsp.injection_generation();
blocking(move || {
let d = ProviderDocument {
path: snap.path.0,
@@ -1450,6 +1525,18 @@ pub async fn plugin_language_visuals(host: &Host, window: &str, doc: DocId) -> I
out.colors = plugins.language_colors(&d);
out.gutter_actions = plugins.language_gutter_actions(&d);
}
project.lsp.set_injections_at(
doc,
d.version as u32,
out.injections
.iter()
.map(|i| ide_lsp::InjectedRange {
range: i.range,
language: ide_core::LanguageId::new(i.language.clone()),
})
.collect(),
generation,
);
Ok(out)
})
.await
+33 -1
Переглянути файл
@@ -1,4 +1,4 @@
//! Embedded plugin-provider documents preserve the host's UTF-16 coordinates.
//! Embedded documents preserve the host's UTF-16 coordinates.
use super::{PluginHost, ProviderKind};
use ide_core::{Pos, Range};
use ide_plugin::{ProviderDocument, language_features::Injection};
@@ -104,6 +104,38 @@ pub(crate) fn virtual_text(doc: &ProviderDocument, ranges: &[Injection], languag
text
}
/// Shared by local and remote projects; all plugin work runs on the host.
static LSP_PROJECTS: parking_lot::Mutex<Vec<std::sync::Weak<ide_lsp::LspManager>>> =
parking_lot::Mutex::new(Vec::new());
pub(super) fn providers_changed() {
let projects = {
let mut projects = LSP_PROJECTS.lock();
projects.retain(|p| p.strong_count() > 0);
projects.iter().filter_map(std::sync::Weak::upgrade).collect::<Vec<_>>()
};
for project in projects {
project.injection_providers_changed();
}
}
pub(crate) fn configure_lsp(lsp: &Arc<ide_lsp::LspManager>) {
LSP_PROJECTS.lock().push(Arc::downgrade(lsp));
lsp.set_injection_provider(Arc::new(|path, language, version, text| {
let Some(plugins) = super::editor_plugin_host() else { return Vec::new() };
plugins
.provide_injections(&ProviderDocument {
path: path.to_path_buf(),
language: language.0.clone(),
version: version.into(),
text: text.to_owned(),
})
.into_iter()
.map(|i| ide_lsp::InjectedRange { range: i.range, language: ide_core::LanguageId::new(i.language) })
.collect()
}));
}
#[cfg(test)]
mod tests {
use super::*;
+1 -1
Переглянути файл
@@ -11,7 +11,7 @@ use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Weak};
pub const MAX_DOCUMENT_BYTES: usize = 2 << 20;
pub const MAX_DOCUMENT_BYTES: usize = ide_lsp::MAX_INJECTED_DOCUMENT_BYTES;
const MAX_FILES: usize = 20_000;
const MAX_EDGES: usize = 4096;
+14
Переглянути файл
@@ -76,16 +76,29 @@ pub struct Providers {
impl Providers {
fn add(self: &Arc<Self>, plugin: String, decl: Decl) -> Disposable {
let injections = decl.kinds.contains(&ProviderKind::Injections);
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.items.write().push((seq, plugin, decl.kinds, decl.languages));
if injections {
self.injections_changed();
}
let weak = Arc::downgrade(self);
Disposable::new(move || {
if let Some(this) = weak.upgrade() {
this.items.write().retain(|(s, ..)| *s != seq);
if injections {
this.injections_changed();
}
}
})
}
fn injections_changed(self: &Arc<Self>) {
if editor_plugin_host().is_some_and(|host| Arc::ptr_eq(&host.providers, self)) {
super::injections::providers_changed();
}
}
/// Plugins providing `kind` for `language`, in declaration order.
pub fn plugins_for(&self, kind: ProviderKind, language: &str) -> Vec<String> {
self.matching::<Vec<_>>(kind, language).into_iter().map(|(plugin, _)| plugin).collect()
@@ -260,6 +273,7 @@ static EDITOR_PROVIDERS: RwLock<Option<Weak<PluginHost>>> = RwLock::new(None);
/// definition and inline completion (`None` removes them).
pub fn set_editor_providers(host: Option<&Arc<PluginHost>>) {
*EDITOR_PROVIDERS.write() = host.map(Arc::downgrade);
super::injections::providers_changed();
}
/// The plugin host whose providers take part, when any declares `kind`.
+1
Переглянути файл
@@ -133,6 +133,7 @@ impl Host {
)?;
mark("index");
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
crate::plugins::injections::configure_lsp(&lsp);
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
lsp.set_content_roots(extra_roots.clone());
let settings = Arc::new(settings);
+132
Переглянути файл
@@ -93,6 +93,9 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let p = plugins(tmp.path());
p.set_workspace(Some(Arc::new(HostWorkspace(Arc::downgrade(&host)))));
set_editor_providers(Some(&p));
let generation = host.project("main").unwrap().lsp.injection_generation();
drop(plugins(&tmp.path().join("unrelated")));
assert_eq!(host.project("main").unwrap().lsp.injection_generation(), generation);
let path = root.join("a.txt");
std::fs::write(&path, "value\nnext\nend").unwrap();
let doc = ops::doc::open(&host, "main", path.clone()).await.unwrap();
@@ -201,6 +204,7 @@ async fn editor_ops_merge_the_fixture_format_selection_and_execute_owned_command
let visual = invoke(&peer, "plugin_language_visuals", serde_json::json!({"doc":doc.id})).await;
assert_eq!(visual["gutterActions"].as_array().unwrap().len(), 1);
set_editor_providers(Some(&p));
injected_lsp(&host, &p, &root, &peer).await;
p.set_enabled(ID, false).unwrap();
assert!(ops::lsp::plugin_formatters().is_empty());
set_editor_providers(None);
@@ -232,3 +236,131 @@ fn formatting_uses_the_command_budget_while_editor_calls_can_expire() {
assert!(p.language_signature_help(&doc, Pos::default()).is_none());
assert!(p.info_of(ID).unwrap().stats.slow_calls > 0);
}
// Same TCP fake-server pattern as plugin_refactoring; the real Wasm export supplies the range.
async fn injected_lsp(host: &Host, plugins: &PluginHost, root: &Path, remote: &ide_jsonrpc::Peer) {
use ide_jsonrpc::{Handler, Peer};
use parking_lot::Mutex;
use serde_json::{Value, json};
#[derive(Default)]
struct Fake {
docs: Mutex<std::collections::HashMap<String, String>>,
requests: Mutex<Vec<(String, Value)>>,
}
impl Handler for Fake {
fn on_notification(&self, method: &str, p: Value) {
let uri = p["textDocument"]["uri"].as_str().unwrap_or_default().to_owned();
match method {
"textDocument/didOpen" => {
self.docs.lock().insert(uri, p["textDocument"]["text"].as_str().unwrap().into());
}
"textDocument/didChange" => {
self.docs.lock().insert(uri, p["contentChanges"][0]["text"].as_str().unwrap().into());
}
"textDocument/didClose" => {
self.docs.lock().remove(&uri);
}
_ => {}
}
}
fn on_request(&self, method: &str, p: Value) -> Result<Value, (i64, String)> {
self.requests.lock().push((method.to_owned(), p.clone()));
Ok(match method {
"initialize" => {
json!({"capabilities": {"textDocumentSync": {"openClose":true,"change":1}, "completionProvider":{"resolveProvider":true}, "hoverProvider":true, "definitionProvider":true}})
}
"textDocument/completion" => json!([{"label":"embedded-server", "data":{"identity":7}}]),
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"identity":7}));
json!({"label":"embedded-server", "detail":"resolved"})
}
"textDocument/hover" => {
json!({"contents":"embedded hover", "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
"textDocument/definition" => {
json!({"uri":p["textDocument"]["uri"], "range":{"start":{"line":0,"character":0},"end":{"line":0,"character":99}}})
}
_ => Value::Null,
})
}
}
let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let port = listener.local_addr().unwrap().port();
let fake = Arc::new(Fake::default());
let peers = Arc::new(Mutex::new(Vec::<Peer>::new()));
let (accepted, state) = (peers.clone(), fake.clone());
let task = tokio::spawn(async move {
while let Ok((stream, _)) = listener.accept().await {
let (r, w) = stream.into_split();
accepted.lock().push(Peer::connect(r, w, state.clone()));
}
});
let cfg: ide_language::LanguageServerConfig =
toml::from_str(&format!("name='injected-sql'\ncommand='no-process-required'\nconnect='127.0.0.1:{port}'"))
.unwrap();
let project = host.project("main").unwrap();
project.lsp.set_servers_hook(Box::new(
move |language, _| if language.as_str() == "sql" { vec![cfg.clone()] } else { Vec::new() },
));
let path = root.join("injection.txt");
std::fs::write(&path, "😀[[sql]] tail").unwrap();
let doc = ops::doc::open(host, "main", path.clone()).await.unwrap();
let items = ops::lsp::lsp_completion(host, "main", RequestId(91), doc.id, Pos::new(0, 5), None).await.unwrap();
let item = items.items.into_iter().find(|i| i.label == "embedded-server").unwrap();
assert_eq!(
ops::lsp::lsp_resolve_completion(host, "main", doc.id, item).await.unwrap().detail.as_deref(),
Some("resolved")
);
let opened = fake.docs.lock().clone();
assert_eq!(opened.len(), 1);
assert_eq!(opened.values().next().unwrap(), " sql ");
let virtual_uri = opened.keys().next().unwrap().clone();
assert!(!ide_lsp::uri_to_path(&virtual_uri).unwrap().exists());
let before = fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count();
ops::lsp::lsp_completion(host, "main", RequestId(92), doc.id, Pos::new(0, 2), None).await.unwrap();
assert_eq!(fake.requests.lock().iter().filter(|(m, _)| m == "textDocument/completion").count(), before);
let defs = ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Declaration).await;
// Declaration isn't advertised; definition exercises mapping without a plugin definition provider.
assert!(defs.is_err());
let defs =
ops::lsp::lsp_goto(host, "main", doc.id, Pos::new(0, 5), ide_lsp::types::GotoKind::Definition).await.unwrap();
assert_eq!(defs[0].path.0, path);
assert_eq!(defs[0].range, Range::new(Pos::new(0, 4), Pos::new(0, 7)));
let hover: Value = remote
.request(
"invoke",
json!({"window":"main","cmd":"lsp_hover","args":{"request":93,"doc":doc.id,"pos":{"line":0,"col":5}}}),
None,
)
.await
.unwrap();
assert!(hover.to_string().contains("embedded hover"), "{hover}");
ops::doc::edit(
host,
"main",
doc.id,
doc.version,
ide_core::ChangeSet { changes: vec![ide_core::TextChange { from: 4, to: 7, insert: "SQL".into() }] },
)
.unwrap();
ops::lsp::lsp_completion(host, "main", RequestId(94), doc.id, Pos::new(0, 5), None).await.unwrap();
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while fake.docs.lock().get(&virtual_uri).is_none_or(|s| s != " SQL ") {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
plugins.set_enabled(ID, false).unwrap();
while !fake.docs.lock().is_empty() {
assert!(tokio::time::Instant::now() < deadline);
tokio::time::sleep(Duration::from_millis(10)).await;
}
assert_eq!(project.lsp.injected_document(doc.id, Pos::new(0, 5)), doc.id);
ops::doc::close(host, "main", doc.id).unwrap();
project.lsp.shutdown_all().await;
project.lsp.set_servers_hook(Box::new(|_, _| Vec::new()));
task.abort();
let _ = task.await;
for peer in peers.lock().drain(..) {
peer.kill();
}
}
+31 -3
Переглянути файл
@@ -17,15 +17,30 @@ pub type Lookup<'a> = Box<dyn Fn(&Path) -> Option<Arc<DocText>> + Send + Sync +
/// Converts server positions to IDE coordinates. For UTF-16 servers this is
/// the identity; otherwise line text comes from open documents or disk.
type MapLocation<'a> = Box<dyn Fn(Location) -> Option<Location> + Send + Sync + 'a>;
type MapEdit<'a> = Box<dyn Fn(&mut WorkspaceEdit) + Send + Sync + 'a>;
pub struct Conv<'a> {
pub enc: Encoding,
map_location: Option<MapLocation<'a>>,
map_edit: Option<MapEdit<'a>>,
lookup: Lookup<'a>,
cache: Mutex<HashMap<PathBuf, Option<Arc<DocText>>>>,
}
impl<'a> Conv<'a> {
pub fn new(enc: Encoding, lookup: impl Fn(&Path) -> Option<Arc<DocText>> + Send + Sync + 'a) -> Self {
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()) }
Self { enc, lookup: Box::new(lookup), cache: Mutex::new(HashMap::new()), map_location: None, map_edit: None }
}
pub fn with_injections(
mut self,
location: impl Fn(Location) -> Option<Location> + Send + Sync + 'a,
edit: impl Fn(&mut WorkspaceEdit) + Send + Sync + 'a,
) -> Self {
self.map_location = Some(Box::new(location));
self.map_edit = Some(Box::new(edit));
self
}
#[cfg(test)]
@@ -62,7 +77,11 @@ impl<'a> Conv<'a> {
pub fn location(&self, loc: &lsp::Location) -> Option<Location> {
let (path, uri) = uri_target(loc.uri.as_str());
let range = self.range(&path, loc.range);
Some(Location { path: path.into(), range, uri })
let loc = Location { path: path.into(), range, uri };
match &self.map_location {
Some(map) => map(loc),
None => Some(loc),
}
}
pub fn text_edit(&self, path: &Path, e: lsp::TextEdit) -> TextEdit {
@@ -662,6 +681,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
}
}
let mut edit = WorkspaceEdit { files, ..Default::default() };
if let Some(map) = &conv.map_edit {
map(&mut edit);
}
edit.deduplicate();
edit
}
@@ -829,7 +851,13 @@ pub fn workspace_symbols(conv: &Conv, r: lsp::WorkspaceSymbolResponse) -> Vec<Wo
lsp::OneOf::Left(l) => conv.location(&l)?,
lsp::OneOf::Right(w) => {
let (path, uri) = uri_target(w.uri.as_str());
Location { path: path.into(), range: Range::default(), uri }
{
let loc = Location { path: path.into(), range: Range::default(), uri };
match &conv.map_location {
Some(map) => map(loc)?,
None => loc,
}
}
}
};
Some(WorkspaceSymbol { name: s.name, kind: symbol_kind(s.kind), container: s.container_name, location })
+3
Переглянути файл
@@ -139,6 +139,9 @@ impl ClientHandler {
}
};
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
if !mgr.accepts_injected_diagnostics(&path, params.version) {
return;
}
// Files opened only for a request (references search) are not the user's problems.
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
&& !mgr.docs.lock().values().any(|d| d.path == path))
+769
Переглянути файл
@@ -0,0 +1,769 @@
//! Masked file documents for plugin-provided embedded languages.
use crate::{
LspManager, convert,
server::Server,
text::Encoding,
types::{Command, CompletionItem},
};
use ide_core::{DocId, FileEdit, IdeError, IdeResult, LanguageId, Location, Pos, Range, WorkspaceEdit};
use parking_lot::{Mutex, RwLock};
use serde_json::{Value, json};
use std::sync::atomic::{AtomicU64, Ordering};
use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::Arc,
time::Duration,
};
pub const MAX_INJECTED_DOCUMENT_BYTES: usize = 2 << 20;
const MAX_DOCUMENTS: usize = 32;
const MARKER: &str = ".openidea-injected-";
const ORIGIN: &str = "$openideaInjection";
pub(super) const COMMAND: &str = "$openideaInjectedCommand";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct InjectedRange {
pub range: Range,
pub language: LanguageId,
}
/// Runs on a blocking worker, with the host's unsaved snapshot.
pub type InjectionProvider = dyn Fn(&Path, &LanguageId, u32, &str) -> Vec<InjectedRange> + Send + Sync;
#[derive(Clone)]
struct Document {
host: DocId,
host_path: PathBuf,
version: u32,
path: PathBuf,
injection: InjectedRange,
active: bool,
}
#[derive(Default)]
pub(super) struct State {
pub gate: Mutex<()>,
provider: RwLock<Option<Arc<InjectionProvider>>>,
documents: RwLock<HashMap<DocId, Document>>,
refreshed: Mutex<HashMap<DocId, u32>>,
refresh: tokio::sync::Mutex<()>,
generation: AtomicU64,
}
fn reserved(path: &Path) -> bool {
path.file_name().is_some_and(|s| {
let name = s.to_string_lossy();
name.rsplit_once(MARKER)
.and_then(|(_, suffix)| suffix.split_once('.'))
.is_some_and(|(id, _)| !id.is_empty() && id.bytes().all(|c| c.is_ascii_digit()))
}) && !path.exists()
}
fn contains(range: Range, pos: Pos) -> bool {
range.start <= pos && pos < range.end
}
fn clip(bounds: Range, range: Range) -> Option<Range> {
if range.start > range.end {
return None;
}
if range.is_empty() {
return contains(bounds, range.start).then_some(range);
}
let start = bounds.start.max(range.start);
let end = bounds.end.min(range.end);
(start < end).then_some(Range::new(start, end))
}
fn confined(bounds: Range, range: Range) -> bool {
range.start <= range.end && bounds.start <= range.start && range.end <= bounds.end
}
fn mask(text: &str, range: Range) -> String {
let mut at = Pos::default();
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if contains(range, at) || matches!(c, '\n' | '\r') {
out.push(c);
} else {
out.extend(std::iter::repeat_n(' ', c.len_utf16()));
}
if c == '\n' {
at.line += 1;
at.col = 0;
} else if c != '\r' {
at.col += c.len_utf16() as u32;
}
}
out
}
impl LspManager {
pub fn set_injection_provider(&self, provider: Arc<InjectionProvider>) {
*self.injections.provider.write() = Some(provider);
}
pub(super) fn schedule_injections(&self, doc: DocId) {
if self.injections.provider.read().is_none() || self.is_injected_doc(doc) {
return;
}
let Some(me) = self.me.upgrade() else { return };
let Ok(snap) = self.snapshot(doc) else { return };
self.runtime.spawn(async move {
tokio::time::sleep(Duration::from_millis(300)).await;
if me.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
let _ = me.refresh_injections(doc).await;
}
});
}
/// Refresh on demand as well as after edits: point requests need not wait for the debounce.
pub async fn refresh_injections(&self, doc: DocId) -> IdeResult<()> {
let _refresh = self.injections.refresh.lock().await;
let generation = self.injections.generation.load(Ordering::Acquire);
let Ok(snap) = self.snapshot(doc) else { return Ok(()) };
if self.is_injected_doc(doc) || self.injections.refreshed.lock().get(&doc) == Some(&(snap.version as u32)) {
return Ok(());
}
let provider = self.injections.provider.read().clone();
let ranges = if snap.text.as_str().len() <= MAX_INJECTED_DOCUMENT_BYTES && !uri_is_virtual(&snap.path) {
if let Some(provider) = provider {
let (path, language, text, version) = (snap.path, snap.language, snap.text, snap.version);
tokio::task::spawn_blocking(move || provider(&path, &language, version as u32, text.as_str()))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?
} else {
Vec::new()
}
} else {
Vec::new()
};
if let Some(me) = self.me.upgrade() {
tokio::task::spawn_blocking(move || me.set_injections_at(doc, snap.version as u32, ranges, generation))
.await
.map_err(|e| IdeError::unavailable(e.to_string()))?;
}
if self.injection_generation() != generation || !self.snapshot(doc).is_ok_and(|s| s.version == snap.version) {
return Err(IdeError::cancelled());
}
Ok(())
}
/// Revision of plugin providers used to reject an obsolete refresh.
pub fn injection_generation(&self) -> u64 {
self.injections.generation.load(Ordering::Acquire)
}
pub fn set_injections(&self, host: DocId, version: u32, ranges: Vec<InjectedRange>) {
self.set_injections_at(host, version, ranges, self.injection_generation());
}
pub fn set_injections_at(&self, host: DocId, version: u32, mut ranges: Vec<InjectedRange>, generation: u64) {
let _gate = self.injections.gate.lock();
if generation != self.injection_generation() {
return;
}
let Ok(snap) = self.snapshot(host) else { return };
if snap.version as u32 != version || self.is_injected_doc(host) {
return;
}
if snap.text.as_str().len() > MAX_INJECTED_DOCUMENT_BYTES {
ranges.clear();
}
ranges.sort_by_key(|r| r.range);
let mut end = Pos::default();
ranges.retain(|i| {
let r = i.range;
let valid_pos = |p: Pos| {
(p.line as usize) < snap.text.line_count()
&& snap.text.lsp_to_ide(Encoding::Utf8, snap.text.ide_to_lsp(Encoding::Utf8, p)) == p
};
if r.start >= r.end
|| r.start < end
|| !valid_pos(r.start)
|| !valid_pos(r.end)
|| i.language == snap.language
|| self.language_servers(&i.language).is_empty()
{
return false;
}
end = r.end;
true
});
ranges.truncate(MAX_DOCUMENTS);
let mut old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
old.sort_by_key(|(_, d)| d.injection.range);
let mut retained = Vec::new();
for (slot, injection) in ranges.into_iter().enumerate() {
let existing =
old.iter().position(|(id, d)| !retained.contains(id) && d.injection == injection).or_else(|| {
old.get(slot)
.filter(|(id, d)| !retained.contains(id) && d.injection.language == injection.language)
.map(|_| slot)
});
let text = mask(snap.text.as_str(), injection.range);
if let Some(index) = existing {
let (id, mut d) = old[index].clone();
if d.injection != injection {
self.clear_injected_diagnostics(&d);
}
d.version = version;
d.injection = injection;
d.active = true;
self.injections.documents.write().insert(id, d);
self.sync_injected(id, &text);
retained.push(id);
} else {
let Some(cfg) = self.languages.get(&injection.language) else { continue };
let ext = cfg
.extensions
.iter()
.find(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric()))
.map(String::as_str)
.unwrap_or("txt");
let mut id = DocId::next();
let path = loop {
let path = snap.path.with_file_name(format!(
".{}{MARKER}{}.{}",
snap.path.file_name().unwrap_or_default().to_string_lossy(),
id.0,
ext
));
if !path.exists() {
break path;
}
id = DocId::next();
};
self.injections.documents.write().insert(
id,
Document {
host,
host_path: snap.path.clone(),
version,
path: path.clone(),
injection: injection.clone(),
active: true,
},
);
let servers = self.servers_for(&injection.language, &path);
self.attach(
id,
path.clone(),
crate::uri::path_to_uri(&path),
&injection.language,
version,
&text,
servers,
);
retained.push(id);
}
}
for (id, d) in old {
if !retained.contains(&id) {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
self.injections.refreshed.lock().insert(host, version);
}
/// Plugin enable/disable/reload changes the ranges even without a buffer edit.
pub fn injection_providers_changed(&self) {
let _gate = self.injections.gate.lock();
self.injections.generation.fetch_add(1, Ordering::AcqRel);
self.injections.refreshed.lock().clear();
let hosts: Vec<_> = self.docs.lock().keys().copied().collect();
for doc in hosts {
if !self.is_injected_doc(doc) {
self.close_injections(doc);
self.schedule_injections(doc);
}
}
}
pub(super) fn map_injected_event(&self, mut event: ide_core::IdeEvent) -> Option<ide_core::IdeEvent> {
if let ide_core::IdeEvent::Diagnostics { path, .. } = &mut event
&& reserved(path)
{
let docs = self.injections.documents.read();
let d = docs.values().find(|d| d.path == path.as_ref())?;
*path = d.host_path.clone().into();
}
Some(event)
}
pub(super) fn injected_request_stamp(&self, params: &Value) -> Option<(DocId, i32)> {
let path = crate::uri::uri_to_path(params.pointer("/textDocument/uri")?.as_str()?)?;
if !reserved(&path) {
return None;
}
self.docs.lock().iter().find(|(_, d)| d.path == path).map(|(id, d)| (*id, d.version))
}
pub(super) fn check_injected_request(&self, doc: DocId, version: i32) -> IdeResult<()> {
let active = self.injections.documents.read().get(&doc).is_some_and(|d| d.active);
if !active || !self.snapshot(doc).is_ok_and(|s| s.version == version) {
return Err(IdeError::cancelled());
}
Ok(())
}
pub(super) fn accepts_injected_diagnostics(&self, path: &Path, version: Option<i32>) -> bool {
if !reserved(path) {
return true;
}
let docs = self.injections.documents.read();
docs.iter().any(|(id, d)| {
d.active && d.path == path && self.snapshot(*id).is_ok_and(|s| version.is_none_or(|v| s.version == v))
})
}
pub fn injected_document(&self, host: DocId, pos: Pos) -> DocId {
let version = self.snapshot(host).ok().map(|s| s.version as u32);
self.injections
.documents
.read()
.iter()
.find(|(_, d)| d.host == host && d.active && Some(d.version) == version && contains(d.injection.range, pos))
.map_or(host, |(id, _)| *id)
}
pub fn injected_request_range(&self, doc: DocId, range: Range) -> Range {
self.clip_injected_range(doc, range).unwrap_or(range)
}
pub(super) fn is_injected_doc(&self, doc: DocId) -> bool {
self.injections.documents.read().contains_key(&doc)
}
pub(super) fn clip_injected_range(&self, doc: DocId, range: Range) -> Option<Range> {
match self.injections.documents.read().get(&doc) {
Some(d) if d.active => clip(d.injection.range, range),
Some(_) => None,
None => Some(range),
}
}
fn sync_injected(&self, id: DocId, text: &str) {
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&id) else { return };
if d.text.as_str() == text {
return;
}
d.version += 1;
Arc::make_mut(&mut d.text).set(text);
for a in &d.servers {
let Some(run) = a.server.running() else { continue };
if a.opened_gen != Some(run.generation) || run.caps.read().sync == crate::SyncKind::None {
continue;
}
let _ = run.peer.notify(
"textDocument/didChange",
json!({
"textDocument": { "uri": d.uri, "version": d.version }, "contentChanges": [{ "text": text }]
}),
);
}
drop(docs);
self.schedule_pull(id, None, Duration::ZERO);
}
pub(super) fn invalidate_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
if old.is_empty() {
return;
}
for (id, d) in old {
if let Some(d) = self.injections.documents.write().get_mut(&id) {
d.active = false;
}
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn close_injections(&self, host: DocId) {
self.injections.refreshed.lock().remove(&host);
let old: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(_, d)| d.host == host)
.map(|(id, d)| (*id, d.clone()))
.collect();
for (id, d) in old {
self.injections.documents.write().remove(&id);
self.close_document(id);
self.clear_injected_diagnostics(&d);
}
}
pub(super) fn map_injected_location(&self, mut loc: Location) -> Option<Location> {
let docs = self.injections.documents.read();
if let Some(d) = docs.values().find(|d| d.path == loc.path.as_ref()) {
if !d.active {
return None;
}
loc.range = clip(d.injection.range, loc.range)?;
loc.path = d.host_path.clone().into();
loc.uri = None;
Some(loc)
} else {
(!reserved(&loc.path)).then_some(loc)
}
}
pub(super) fn map_injected_edit(&self, edit: &mut WorkspaceEdit) {
let docs = self.injections.documents.read();
edit.files.retain_mut(|f| match f {
FileEdit::Edit { path, edits, version } => {
if let Some(d) = docs.values().find(|d| d.path == path.as_ref()) {
if !d.active {
return false;
}
// Clipping replacements changes their meaning; discard edits crossing a boundary.
edits.retain(|e| confined(d.injection.range, e.range));
*path = d.host_path.clone().into();
*version = Some(d.version as i32);
!edits.is_empty()
} else {
!reserved(path)
}
}
FileEdit::Create { path, .. } | FileEdit::Delete { path, .. } => {
!reserved(path) && !docs.values().any(|d| d.path == path.as_ref())
}
FileEdit::Rename { from, to } => {
!reserved(from)
&& !reserved(to)
&& !docs.values().any(|d| d.path == from.as_ref() || d.path == to.as_ref())
}
});
}
pub(super) fn provides_injected_command(&self, host: DocId, server: Option<ide_core::ServerId>) -> bool {
let ids: Vec<_> = self
.injections
.documents
.read()
.iter()
.filter(|(id, d)| d.host == host || **id == host)
.map(|(id, _)| *id)
.collect();
let docs = self.docs.lock();
ids.iter()
.any(|id| docs.get(id).is_some_and(|d| d.servers.iter().any(|a| server.is_none_or(|s| a.server.id == s))))
}
pub(super) fn tag_injected_command(&self, doc: DocId, server: ide_core::ServerId, command: &mut Option<Command>) {
let Some(original) = command.take() else { return };
if self.is_injected_doc(doc) {
let mut data = Some(serde_json::to_value(&original).expect("command is JSON"));
self.tag_injected_origin(doc, &mut data);
*command = Some(Command {
title: original.title,
command: COMMAND.into(),
arguments: vec![json!({ "origin": data, "server": server })],
});
} else {
*command = Some(original);
}
}
pub(super) fn injected_command(
&self,
host: DocId,
command: Command,
) -> IdeResult<(DocId, Command, Option<ide_core::ServerId>)> {
if command.command != COMMAND {
return Ok((host, command, None));
}
let payload = command.arguments.first().ok_or_else(IdeError::cancelled)?;
let origin = payload.pointer("/origin/$openideaInjection").ok_or_else(IdeError::cancelled)?;
let doc =
origin["doc"].as_u64().and_then(|id| u32::try_from(id).ok()).map(DocId).ok_or_else(IdeError::cancelled)?;
// Acceptance may already have edited the host. Keep the server/document identity;
// execute refreshes its mask first, and cannot revive a disappeared injection.
if !self.injections.documents.read().get(&doc).is_some_and(|d| d.host == host || doc == host) {
return Err(IdeError::cancelled());
}
let command = serde_json::from_value(origin["data"].clone()).map_err(|_| IdeError::cancelled())?;
let server =
payload["server"].as_u64().map(|id| ide_core::ServerId(id as u32)).ok_or_else(IdeError::cancelled)?;
Ok((doc, command, Some(server)))
}
pub(super) fn tag_injected_origin(&self, doc: DocId, data: &mut Option<Value>) {
if let Some(d) = self.injections.documents.read().get(&doc) {
*data = Some(json!({ ORIGIN: { "doc": doc, "version": d.version, "data": data.take() } }));
}
}
pub(super) fn resolve_injected_origin(&self, host: DocId, data: &mut Option<Value>) -> IdeResult<DocId> {
let Some(origin) = data.as_ref().and_then(|v| v.get(ORIGIN)) else { return Ok(host) };
let doc = origin["doc"].as_u64().map(|id| DocId(id as u32)).ok_or_else(IdeError::cancelled)?;
let docs = self.injections.documents.read();
let d = docs
.get(&doc)
.filter(|d| {
(d.host == host || doc == host) && d.active && origin["version"].as_u64() == Some(d.version.into())
})
.ok_or_else(IdeError::cancelled)?;
if self.snapshot(d.host)?.version as u32 != d.version {
return Err(IdeError::cancelled());
}
*data = origin.get("data").filter(|v| !v.is_null()).cloned();
Ok(doc)
}
pub(super) fn bound_completion(&self, doc: DocId, item: &mut CompletionItem) -> bool {
let docs = self.injections.documents.read();
let Some(d) = docs.get(&doc) else { return !self.snapshot(doc).is_ok_and(|s| reserved(&s.path)) };
if !d.active {
return false;
}
for r in [&mut item.replace_range, &mut item.replace_range_tab].into_iter().flatten() {
let Some(clipped) = clip(d.injection.range, *r) else { return false };
*r = clipped;
}
item.additional_edits.retain(|e| confined(d.injection.range, e.range));
drop(docs);
self.tag_injected_command(doc, item.server, &mut item.command);
self.tag_injected_origin(doc, &mut item.data);
true
}
fn clear_injected_diagnostics(&self, d: &Document) {
let servers = self.servers.lock().clone();
for server in servers {
server.set_diagnostics(d.path.clone(), Vec::new());
server.clear_pulled(&d.path);
let items = server.diagnostics_for(&d.host_path);
let enc = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
self.emit_diagnostics(&server, d.host_path.clone(), items, enc);
}
}
/// One event per host/source includes every fragment and the server's native host diagnostics.
pub(super) fn injected_diagnostics(
&self,
server: &Server,
path: &Path,
enc: Encoding,
native: Vec<lsp_types::Diagnostic>,
) -> Option<(PathBuf, Vec<ide_core::Diagnostic>)> {
let docs: Vec<_> = self.injections.documents.read().values().cloned().collect();
let host_path = if let Some(d) = docs.iter().find(|d| d.path == path) {
d.host_path.clone()
} else if reserved(path) {
return None;
} else {
path.to_owned()
};
let conv = self.conv(enc);
let native = if path == host_path { native } else { server.diagnostics_for(&host_path) };
let mut items: Vec<_> =
native.into_iter().map(|d| convert::diagnostic(&conv, &host_path, d, &server.name)).collect();
for d in docs.iter().filter(|d| d.active && d.host_path == host_path) {
items.extend(server.diagnostics_for(&d.path).into_iter().filter_map(|diag| {
let mut diag = convert::diagnostic(&conv, &d.path, diag, &server.name);
if !confined(d.injection.range, diag.range)
|| (diag.range.is_empty() && !contains(d.injection.range, diag.range.start))
{
return None;
}
diag.source = Some(server.name.clone());
Some(diag)
}));
}
(!reserved(&host_path)).then_some((host_path, items))
}
}
fn uri_is_virtual(path: &Path) -> bool {
ide_core::is_virtual_uri(&path.to_string_lossy())
}
#[cfg(test)]
mod tests {
use super::*;
struct Missing;
impl crate::Launcher for Missing {
fn launch(
&self,
_: &ide_language::LanguageServerConfig,
_: &Path,
_: Arc<dyn ide_jsonrpc::Handler>,
_: Box<dyn Fn(String) + Send + Sync>,
) -> Result<ide_jsonrpc::Peer, crate::LaunchError> {
Err(crate::LaunchError::Missing)
}
}
fn manager() -> (Arc<LspManager>, tempfile::TempDir, Arc<Mutex<Vec<ide_core::IdeEvent>>>) {
let root = tempfile::tempdir().unwrap();
let events = Arc::new(Mutex::new(Vec::new()));
let output = events.clone();
let mgr = LspManager::with_launcher(
root.path().to_path_buf(),
Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
Arc::new(move |e| output.lock().push(e)),
Arc::new(Missing),
);
(mgr, root, events)
}
fn injection(a: u32, b: u32) -> InjectedRange {
InjectedRange { range: Range::new(Pos::new(0, a), Pos::new(0, b)), language: LanguageId::new("sql") }
}
#[test]
fn routing_mapping_edits_and_limits() {
let (mgr, root, _) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "😀 select tail");
mgr.set_injections(host, 1, vec![injection(3, 9)]);
let embedded = mgr.injected_document(host, Pos::new(0, 3));
assert_ne!(host, embedded);
assert_eq!(mgr.injected_document(host, Pos::new(0, 2)), host);
assert_eq!(mgr.injected_document(host, Pos::new(0, 9)), host);
let virtual_path = mgr.snapshot(embedded).unwrap().path;
assert_eq!(virtual_path.parent(), path.parent());
assert_eq!(virtual_path.extension().unwrap(), "sql");
assert!(!virtual_path.exists());
let loc = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 99)));
let mapped = mgr.map_injected_location(loc).unwrap();
assert_eq!(mapped.path.0, path);
assert_eq!(mapped.range, injection(3, 9).range);
let outside = Location::file(virtual_path.clone(), Range::new(Pos::default(), Pos::new(0, 2)));
assert!(mgr.map_injected_location(outside).is_none());
let real = root.path().join(".real.openidea-injected-123.sql");
std::fs::write(&real, "real file").unwrap();
let other = Location::file(real, Range::default());
assert_eq!(mgr.map_injected_location(other.clone()), Some(other));
let mut edit = WorkspaceEdit {
files: vec![
FileEdit::Edit {
path: virtual_path.clone().into(),
edits: vec![
ide_core::TextEdit { range: injection(3, 9).range, new_text: "SELECT".into() },
ide_core::TextEdit { range: injection(0, 9).range, new_text: "bad".into() },
],
version: Some(999),
},
FileEdit::Create { path: virtual_path.clone().into(), overwrite: true },
FileEdit::Rename { from: path.clone().into(), to: virtual_path.clone().into() },
FileEdit::Delete { path: virtual_path.clone().into(), recursive: false },
],
..Default::default()
};
mgr.map_injected_edit(&mut edit);
assert_eq!(edit.files.len(), 1);
assert!(
matches!(&edit.files[0], FileEdit::Edit { path: p, edits, version: Some(1) } if p.0 == path && edits.len() == 1)
);
mgr.set_injections(host, 0, vec![]);
assert_ne!(mgr.injected_document(host, Pos::new(0, 5)), host, "stale refresh ignored");
mgr.set_injections(host, 1, vec![]);
assert_eq!(mgr.injected_document(host, Pos::new(0, 5)), host);
assert!(mgr.map_injected_location(Location::file(virtual_path, injection(3, 9).range)).is_none());
mgr.did_open(host, &path, &LanguageId::plain_text(), 2, &"a ".repeat(40));
mgr.set_injections(host, 2, (0..40).map(|n| injection(n * 2, n * 2 + 1)).collect());
assert_eq!(mgr.injections.documents.read().len(), 32);
mgr.did_close(host);
assert!(mgr.injections.documents.read().is_empty());
mgr.did_open(host, &path, &LanguageId::plain_text(), 3, &"a".repeat(MAX_INJECTED_DOCUMENT_BYTES + 1));
mgr.set_injections(host, 3, vec![injection(0, 1)]);
assert!(mgr.injections.documents.read().is_empty());
}
#[test]
fn diagnostics_merge_fragments_native_source_and_clear() {
let (mgr, root, events) = manager();
let host = DocId::next();
let path = root.path().join("a.java");
mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "xxsqlxxsql");
mgr.set_injections(host, 1, vec![injection(2, 5), injection(7, 10)]);
let a = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 2))).unwrap();
let b = mgr.snapshot(mgr.injected_document(host, Pos::new(0, 7))).unwrap();
let server = a.servers[0].clone();
let diag = |start, end, message: &str| lsp_types::Diagnostic {
range: lsp_types::Range::new(lsp_types::Position::new(0, start), lsp_types::Position::new(0, end)),
message: message.into(),
source: Some("untrusted-source".into()),
..Default::default()
};
server.set_diagnostics(path.clone(), vec![diag(0, 1, "native")]);
server.set_diagnostics(a.path.clone(), vec![diag(2, 5, "a"), diag(0, 1, "outside"), diag(4, 6, "crosses")]);
let items = server.set_diagnostics(b.path.clone(), vec![diag(7, 10, "b")]);
mgr.emit_diagnostics(&server, b.path, items, Encoding::Utf16);
let latest = || {
events
.lock()
.iter()
.rev()
.find_map(|e| match e {
ide_core::IdeEvent::Diagnostics { path: p, source, items }
if p.0 == path && source == &server.name =>
{
Some(items.clone())
}
_ => None,
})
.unwrap()
};
assert_eq!(latest().iter().map(|d| d.message.as_str()).collect::<Vec<_>>().len(), 3);
assert!(
latest()
.iter()
.filter(|d| d.message != "native")
.all(|d| d.source.as_deref() == Some(server.name.as_str()))
);
mgr.set_injections(host, 1, vec![injection(7, 10)]);
assert_eq!(latest().iter().map(|d| d.message.clone()).collect::<Vec<_>>(), ["native", "b"]);
mgr.set_injections(host, 1, vec![]);
assert_eq!(latest()[0].message, "native");
assert_eq!(latest().len(), 1);
assert!(
events.lock().iter().all(|e| !matches!(e, ide_core::IdeEvent::Diagnostics { path, .. } if reserved(path)))
);
}
#[test]
fn masking_preserves_utf16_crlf_and_line_edges() {
let text = "😀éSELECT\r\n中sql\r\nend";
let range = Range::new(Pos::new(0, 3), Pos::new(1, 4));
assert_eq!(mask(text, range), " SELECT\r\n中sql\r\n ");
assert_eq!(mask(text, range).encode_utf16().count(), text.encode_utf16().count());
assert_eq!(mask("a\nsql\nz", Range::new(Pos::new(1, 0), Pos::new(2, 0))), " \nsql\n ");
assert_eq!(mask("sql", Range::new(Pos::new(0, 0), Pos::new(0, 3))), "sql");
}
#[test]
fn boundaries_are_half_open_and_results_clip() {
let r = Range::new(Pos::new(1, 3), Pos::new(2, 5));
assert!(contains(r, r.start));
assert!(!contains(r, r.end));
assert!(!contains(r, Pos::new(1, 2)));
assert_eq!(clip(r, Range::new(Pos::new(0, 0), Pos::new(3, 0))), Some(r));
assert_eq!(clip(r, Range::point(r.end)), None);
assert_eq!(clip(r, Range::new(Pos::new(0, 0), r.start)), None);
}
}
+131 -37
Переглянути файл
@@ -15,6 +15,7 @@ mod file_ops;
mod file_watch;
mod go_scope;
mod handler;
mod injections;
mod inspect;
pub mod jdtls;
mod launch;
@@ -31,6 +32,7 @@ pub mod types;
mod uri;
mod virtual_doc;
pub use injections::{InjectedRange, InjectionProvider, MAX_INJECTED_DOCUMENT_BYTES};
pub use inspect::{FileInspection, InspectHost, InspectOptions};
pub use launch::{LaunchError, Launcher, ProcessLauncher};
pub use scope::{CandidateFiles, CandidateFn, CandidateQuery, MAX_CANDIDATES};
@@ -196,6 +198,7 @@ pub struct LspManager {
virtual_origins: Mutex<HashMap<String, ide_core::ServerId>>,
/// Contents fetched for virtual URIs.
virtual_docs: Mutex<HashMap<String, VirtualDocument>>,
injections: injections::State,
}
/// Fetched virtual documents kept before the cache is reset.
@@ -361,12 +364,23 @@ impl LspManager {
sink: SharedSink,
launcher: Arc<dyn Launcher>,
) -> Arc<Self> {
Arc::new_cyclic(|me| Self {
Arc::<Self>::new_cyclic(|me| Self {
me: me.clone(),
project_root,
content_roots: RwLock::new(Vec::new()),
languages,
sink,
sink: {
let me = me.clone();
Arc::new(move |event| {
if let Some(mgr) = me.upgrade() {
if let Some(event) = mgr.map_injected_event(event) {
sink.emit(event);
}
} else {
sink.emit(event);
}
})
},
launcher,
runtime: runtime_handle(),
servers: Mutex::new(Vec::new()),
@@ -386,6 +400,7 @@ impl LspManager {
shown_messages: Mutex::new(HashSet::new()),
virtual_origins: Mutex::new(HashMap::new()),
virtual_docs: Mutex::new(HashMap::new()),
injections: injections::State::default(),
})
}
@@ -812,22 +827,27 @@ impl LspManager {
pub(crate) fn conv(&self, enc: Encoding) -> Conv<'_> {
Conv::new(enc, move |p: &Path| self.text_for_path(p))
.with_injections(move |loc| self.map_injected_location(loc), move |edit| self.map_injected_edit(edit))
}
// ---- document sync (driven by ide-workspace's DocumentObserver) ----
pub fn did_open(&self, doc: DocId, path: &Path, language: &LanguageId, version: u32, text: &str) {
let _gate = self.injections.gate.lock();
if self.docs.lock().contains_key(&doc) {
self.did_close(doc);
self.close_injections(doc);
self.close_document(doc);
}
let servers = self.servers_for(language, path);
self.attach(doc, path.to_owned(), uri::path_to_uri(path), language, version, text, servers);
self.schedule_injections(doc);
}
/// Opens a read-only virtual document (`jdt://…`) on the server that
/// returned its URI (`didOpen` only: it is never changed or saved).
pub fn did_open_virtual(&self, doc: DocId, uri: &str, language: &LanguageId, version: u32, text: &str) {
if self.docs.lock().contains_key(&doc) {
self.did_close(doc);
self.close_injections(doc);
self.close_document(doc);
}
let Ok(lsp_uri) = uri.parse::<lsp::Uri>() else {
tracing::debug!("not a valid URI: {uri}");
@@ -887,6 +907,7 @@ impl LspManager {
}
pub fn did_change(&self, doc: DocId, version: u32, changes: &[AppliedChange], full_text: &dyn Fn() -> String) {
let _gate = self.injections.gate.lock();
let mut docs = self.docs.lock();
let Some(d) = docs.get_mut(&doc) else { return };
d.version = version as i32;
@@ -929,9 +950,6 @@ impl LspManager {
tracing::warn!("lsp text copy of {} out of sync; resending full text", d.path.display());
text.set(&full_text());
}
if targets.is_empty() {
return;
}
let full = json!([{ "text": text.as_str() }]);
for (server, run, sync, enc) in targets {
let key = match enc {
@@ -954,6 +972,8 @@ impl LspManager {
}
drop(docs);
self.schedule_pull(doc, None, pull::AFTER_EDIT);
self.invalidate_injections(doc);
self.schedule_injections(doc);
}
/// Sends the documents open on instance `generation` of `server` again, as a `didChange`
@@ -1004,6 +1024,12 @@ impl LspManager {
}
pub fn did_close(&self, doc: DocId) {
let _gate = self.injections.gate.lock();
self.close_injections(doc);
self.close_document(doc);
}
fn close_document(&self, doc: DocId) {
let Some(d) = self.docs.lock().remove(&doc) else { return };
for a in &d.servers {
self.forget_pulled(&a.server, &d.path, a.server.running().as_ref());
@@ -1067,19 +1093,25 @@ impl LspManager {
cancel: Option<&CancellationToken>,
timeout: Duration,
) -> IdeResult<R> {
let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?;
let stamp = self.injected_request_stamp(&params);
let fut = async {
if t.server.name == "csharp-ls" {
let mut value = t.run.peer.request::<P, Value>(method, params, cancel.cloned()).await?;
let mut value = t.run.peer.request::<Value, Value>(method, params, cancel.cloned()).await?;
virtual_doc::normalize_csharp_uris(&mut value);
serde_json::from_value(value).map_err(|e| RpcError::Protocol(format!("{method}: {e}")))
} else {
t.run.peer.request::<P, R>(method, params, cancel.cloned()).await
t.run.peer.request::<Value, R>(method, params, cancel.cloned()).await
}
};
match tokio::time::timeout(timeout, fut).await {
let result = match tokio::time::timeout(timeout, fut).await {
Ok(r) => r.map_err(|e| rpc_err(&t.server.name, method, e)),
Err(_) => Err(rpc_err(&t.server.name, method, RpcError::Timeout)),
};
if let Some((doc, version)) = stamp {
self.check_injected_request(doc, version)?;
}
result
}
// ---- features ----
@@ -1114,8 +1146,10 @@ impl LspManager {
let conv = self.conv(t.enc());
let list = out.value.get_or_insert(CompletionList { is_incomplete: false, items: Vec::new() });
list.is_incomplete |= incomplete;
list.items
.extend(items.into_iter().map(|i| convert::completion_item(&conv, &snap.path, i, t.server.id)));
list.items.extend(items.into_iter().filter_map(|i| {
let mut item = convert::completion_item(&conv, &snap.path, i, t.server.id);
self.bound_completion(doc, &mut item).then_some(item)
}));
}
Err(e) => out.fail(e),
}
@@ -1157,21 +1191,33 @@ impl LspManager {
out.finish(Vec::new())
}
pub async fn resolve_completion(&self, doc: DocId, item: CompletionItem) -> IdeResult<CompletionItem> {
pub async fn resolve_completion(&self, doc: DocId, mut item: CompletionItem) -> IdeResult<CompletionItem> {
let original = item.clone();
let host_doc = doc;
let doc = self.resolve_injected_origin(doc, &mut item.data)?;
let snap = self.snapshot(doc)?;
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(item) };
let Some(server) = snap.servers.iter().find(|s| s.id == item.server) else { return Ok(original) };
let Some(t) = self
.ready_targets(std::slice::from_ref(server), |c| c.has_sub("completionProvider", "resolveProvider"))
.await
.pop()
else {
return Ok(item);
return Ok(original);
};
if let Some(command) = item.command.take() {
item.command = Some(self.injected_command(doc, command)?.1);
}
let lsp_item = convert::lsp_completion_item(&snap.text, t.enc(), &item);
let resolved: lsp::CompletionItem =
self.call(&t, "completionItem/resolve", lsp_item, None, DEFAULT_TIMEOUT).await?;
if doc != host_doc {
self.check_injected_request(doc, snap.version)?;
}
let mut out = item;
convert::overlay_completion(&self.conv(t.enc()), &snap.path, &mut out, resolved, false);
if !self.bound_completion(doc, &mut out) {
return Err(IdeError::cancelled());
}
Ok(out)
}
@@ -1188,7 +1234,11 @@ impl LspManager {
// Still loading: no documentation yet, not an error.
Err(e) if e.kind == ErrorKind::TimedOut && t.server.is_loading() => out.value = Some(None),
Ok(Some(h)) => {
if let Some(h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
if let Some(mut h) = convert::hover(&self.conv(t.enc()), &snap.path, h) {
if let Some(range) = h.range {
let Some(range) = self.clip_injected_range(doc, range) else { continue };
h.range = Some(range);
}
return Ok(Some(h));
}
out.value = Some(None);
@@ -1697,7 +1747,14 @@ impl LspManager {
match r {
Ok(Some(hs)) if !hs.is_empty() => {
let conv = self.conv(t.enc());
return Ok(hs.into_iter().map(|h| convert::highlight(&conv, &snap.path, h)).collect());
return Ok(hs
.into_iter()
.filter_map(|h| {
let mut h = convert::highlight(&conv, &snap.path, h);
h.range = self.clip_injected_range(doc, h.range)?;
Some(h)
})
.collect());
}
Ok(_) => out.value = Some(Vec::new()),
Err(e) => out.fail(e),
@@ -1848,7 +1905,12 @@ impl LspManager {
actions
.unwrap_or_default()
.into_iter()
.map(|a| convert::code_action(&conv, a, t.server.id))
.map(|a| {
let mut action = convert::code_action(&conv, a, t.server.id);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
action
})
// Servers may ignore `only`, and the kinds kept for this one.
.filter(|a| caps.restriction.keeps_action(a.kind.as_deref())),
);
@@ -1859,7 +1921,10 @@ impl LspManager {
out.finish(Vec::new()).map(|actions| CodeActionQuery { actions, advertised_kinds })
}
pub async fn resolve_code_action(&self, doc: DocId, action: CodeAction) -> IdeResult<CodeAction> {
pub async fn resolve_code_action(&self, doc: DocId, mut action: CodeAction) -> IdeResult<CodeAction> {
let original = action.clone();
let host_doc = doc;
let doc = self.resolve_injected_origin(doc, &mut action.data)?;
let snap = self.snapshot(doc)?;
let servers: Vec<Arc<Server>> = match snap.servers.iter().find(|s| s.id == action.server) {
Some(s) => vec![s.clone()],
@@ -1867,19 +1932,33 @@ impl LspManager {
};
let Some(t) = self.ready_targets(&servers, |c| c.has_sub("codeActionProvider", "resolveProvider")).await.pop()
else {
return Ok(action);
return Ok(original);
};
if let Some(command) = action.command.take() {
action.command = Some(self.injected_command(doc, command)?.1);
}
let resolved: lsp::CodeAction =
self.call(&t, "codeAction/resolve", convert::lsp_code_action(&action), None, DEFAULT_TIMEOUT).await?;
Ok(convert::merge_code_action(&self.conv(t.enc()), action, resolved))
if doc != host_doc {
self.check_injected_request(doc, snap.version)?;
}
let mut action = convert::merge_code_action(&self.conv(t.enc()), action, resolved);
self.tag_injected_origin(doc, &mut action.data);
self.tag_injected_command(doc, action.server, &mut action.command);
Ok(action)
}
pub async fn execute_command(&self, doc: DocId, command: Command) -> IdeResult<()> {
if command.command == injections::COMMAND {
self.refresh_injections(doc).await?;
}
let (doc, command, server) = self.injected_command(doc, command)?;
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
};
let name = command.command.clone();
let servers: Vec<_> = servers.into_iter().filter(|s| server.is_none_or(|id| s.id == id)).collect();
let targets = self.ready_targets(&servers, |c| c.commands().contains(&name)).await;
let t = targets
.first()
@@ -1893,6 +1972,9 @@ impl LspManager {
/// can carry commands other editors' extensions run themselves after the edit (vtsls'
/// `_typescript.didOrganizeImports`): no server provides those.
pub async fn provides_command(&self, doc: DocId, server: Option<ide_core::ServerId>, name: &str) -> bool {
if name == injections::COMMAND {
return self.provides_injected_command(doc, server);
}
let servers = match self.snapshot(doc) {
Ok(snap) => snap.servers,
Err(_) => self.servers.lock().clone(),
@@ -1921,6 +2003,10 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Option<CommandForm>> {
let (doc, command, pinned) = self.injected_command(doc, command)?;
if pinned.is_some_and(|id| id != server) {
return Err(IdeError::cancelled());
}
// Nothing to ask for a command its server doesn't provide (executing it decides).
if !self.provides_command(doc, Some(server), &command.command).await {
return Ok(None);
@@ -1944,17 +2030,19 @@ impl LspManager {
return Err(IdeError::unavailable("unsupported command input type"));
}
}
let mut command = Some(Command {
title: command.title,
command: response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::internal("command form omitted command"))?
.to_owned(),
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
.map_err(parse_error)?,
});
self.tag_injected_command(doc, server, &mut command);
Ok(Some(CommandForm {
command: Command {
title: command.title,
command: response
.get("command")
.and_then(Value::as_str)
.ok_or_else(|| IdeError::internal("command form omitted command"))?
.to_owned(),
arguments: serde_json::from_value(response.get("arguments").cloned().unwrap_or(json!([])))
.map_err(parse_error)?,
},
command: command.expect("resolved command"),
form_fields: serde_json::from_value(fields).map_err(parse_error)?,
form_answers: serde_json::from_value(
response.get("formAnswers").filter(|v| !v.is_null()).cloned().unwrap_or(json!([])),
@@ -1972,6 +2060,13 @@ impl LspManager {
answers: Vec<FormAnswer>,
cancel: CancellationToken,
) -> IdeResult<Vec<PathBuf>> {
if command.command == injections::COMMAND {
self.refresh_injections(doc).await?;
}
let (doc, command, pinned) = self.injected_command(doc, command)?;
if pinned.is_some_and(|id| id != server) {
return Err(IdeError::cancelled());
}
let t = self.command_target(doc, server, &command.command).await?;
let open_created = t.server.name == "gopls" && command.command == "gopls.add_test";
let mut edits = self.applied_edits.subscribe();
@@ -2877,6 +2972,9 @@ impl LspManager {
self.ensure_started(server);
}
}
if allowed != previous {
self.injection_providers_changed();
}
}
/// [`Self::reconfigure`] on the manager's runtime, for callers outside one (file watchers).
@@ -2945,6 +3043,7 @@ impl LspManager {
}
self.attach_fallbacks();
self.detach_shadowed_fallbacks().await;
self.injection_providers_changed();
}
/// A server enabled again shadows the fallbacks attached in its place: they
@@ -3053,13 +3152,8 @@ impl LspManager {
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
for server in servers {
let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
let conv = self.conv(encoding);
for (path, items) in server.diagnostics_by_path() {
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(
&self.sink,
ide_core::IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items },
);
self.emit_diagnostics(&server, path, items, encoding);
}
}
}
+4 -4
Переглянути файл
@@ -5,7 +5,7 @@
//! and shown together.
use crate::server::{Running, Server};
use crate::{LspManager, convert, virtual_doc};
use crate::{LspManager, virtual_doc};
use ide_core::{DocId, IdeEvent};
use ide_jsonrpc::RpcError;
use lsp_types as lsp;
@@ -189,8 +189,8 @@ impl LspManager {
items: Vec<lsp::Diagnostic>,
enc: crate::text::Encoding,
) {
let conv = self.conv(enc);
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
if let Some((path, items)) = self.injected_diagnostics(server, &path, enc, items) {
server.emit(&self.sink, IdeEvent::Diagnostics { path: path.into(), source: server.name.clone(), items });
}
}
}
-1
Переглянути файл
@@ -50,7 +50,6 @@ impl DocText {
&self.text
}
#[cfg(test)]
pub fn line_count(&self) -> usize {
self.lines.len()
}
+189
Переглянути файл
@@ -5117,3 +5117,192 @@ async fn hierarchy_refactorings_name_the_servers_without_them() {
assert_eq!(e.kind, ide_core::error::ErrorKind::Unavailable);
assert_eq!(e.message, "plain doesn't support Pull Members Up");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn injected_documents_route_and_resolve_without_exposing_virtual_files() {
for encoding in ["utf-16", "utf-8"] {
let mut caps = incremental_caps();
caps["positionEncoding"] = json!(encoding);
caps["signatureHelpProvider"] = json!({});
for cap in
["declarationProvider", "typeDefinitionProvider", "implementationProvider", "documentHighlightProvider"]
{
caps[cap] = json!(true);
}
let mut h = harness_for("sql", "sql", &[("embedded", caps)], &[]);
let builtins = LanguageRegistry::load(None).unwrap();
let mut sql = builtins.get(&LanguageId::new("sql")).unwrap();
sql.language_servers = vec![server_cfg("embedded")];
let mut native = builtins.get(&LanguageId::plain_text()).unwrap();
native.language_servers = vec![server_cfg("host")];
*h.launcher.state("host").caps.lock() = incremental_caps();
h.launcher.state("host").respond("textDocument/completion", json!([{"label":"native"}]));
let events = h.events.clone();
h.mgr = LspManager::with_launcher(
h.root.clone(),
Arc::new(LanguageRegistry::from_configs(vec![sql, native])),
Arc::new(move |e| events.lock().push(e)),
h.launcher.clone(),
);
let state = h.launcher.state("embedded");
*state.responder.lock() = Some(Arc::new(|_, method, p| {
let whole = json!({"start":{"line":0,"character":0},"end":{"line":0,"character":99}});
let uri = p["textDocument"]["uri"].clone();
Some(match method {
"textDocument/completion" => {
json!([{"label":"one","textEdit":{"range":whole,"newText":"ONE"},"data":{"id":7},"command":{"title":"run","command":"mock.run"}}])
}
"completionItem/resolve" => {
assert_eq!(p["data"], json!({"id":7}));
assert_eq!(p["command"]["command"], "mock.run");
json!({"label":"one","detail":"resolved"})
}
"textDocument/hover" => json!({"contents":"SQL","range":whole}),
"textDocument/signatureHelp" => json!({"signatures":[{"label":"sql()"}]}),
"textDocument/definition"
| "textDocument/declaration"
| "textDocument/typeDefinition"
| "textDocument/implementation" => json!([
{"uri":uri,"range":whole},
{"uri":uri,"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}}}
]),
"textDocument/references" => json!([{"uri":uri,"range":whole}]),
"textDocument/documentHighlight" => json!([{"range":whole}]),
"textDocument/codeAction" => json!([{"title":"fix","data":{"uri":uri}}]),
"codeAction/resolve" => json!({"title":"fix","edit":{"documentChanges":[
{"textDocument":{"uri":p["data"]["uri"],"version":1},"edits":[{"range":{"start":{"line":0,"character":4},"end":{"line":0,"character":7}},"newText":"ONE"}]},
{"kind":"create","uri":p["data"]["uri"]}
]},"command":{"title":"run","command":"mock.run"}}),
_ => Value::Null,
})
}));
h.mgr.set_injection_provider(Arc::new(|_, _, _, text| {
text.match_indices("[[")
.filter_map(|(start, _)| {
let end = start + 2 + text[start + 2..].find("]]")?;
Some(ide_lsp::InjectedRange {
range: Range::new(
Pos::new(0, text[..start + 2].encode_utf16().count() as u32),
Pos::new(0, text[..end].encode_utf16().count() as u32),
),
language: LanguageId::new("sql"),
})
})
.collect()
}));
let host = DocId::next();
let path = h.root.join("host.java");
let source = "😀[[one]] [[two]]";
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, source);
h.mgr.refresh_injections(host).await.unwrap();
let doc = h.mgr.injected_document(host, Pos::new(0, 5));
let second = h.mgr.injected_document(host, Pos::new(0, 13));
assert_ne!(doc, host);
assert_ne!(doc, second, "separate SQL fragments must not share a document");
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 7)), host);
assert_eq!(
h.mgr.completion(host, Pos::new(0, 1), None, Default::default()).await.unwrap().items[0].label,
"native"
);
let item = h.mgr.completion(doc, Pos::new(0, 5), None, Default::default()).await.unwrap().items.remove(0);
assert_eq!(item.replace_range, Some(rng(0, 4, 0, 7)));
let resolved = h.mgr.resolve_completion(host, item).await.unwrap();
assert_eq!(resolved.detail.as_deref(), Some("resolved"));
h.mgr.execute_command(host, resolved.command.clone().unwrap()).await.unwrap();
assert_eq!(state.requested("workspace/executeCommand")[0]["command"], "mock.run");
assert_eq!(
h.mgr.hover(doc, Pos::new(0, 5), Default::default()).await.unwrap().unwrap().range,
Some(rng(0, 4, 0, 7))
);
assert!(h.mgr.signature_help(doc, Pos::new(0, 5)).await.unwrap().is_some());
for kind in [GotoKind::Definition, GotoKind::Declaration, GotoKind::TypeDefinition, GotoKind::Implementation] {
let locations = h.mgr.goto(doc, Pos::new(0, 5), kind).await.unwrap();
assert_eq!(locations.len(), 1);
assert_eq!(locations[0].path.0, path);
assert_eq!(locations[0].range, rng(0, 4, 0, 7));
}
let refs = h.mgr.references(doc, Pos::new(0, 5), true, false, Default::default()).await.unwrap();
assert_eq!(refs.locations[0].path.0, path);
let highlights = h.mgr.document_highlights(doc, Pos::new(0, 5)).await.unwrap();
assert_eq!(highlights[0].range, rng(0, 4, 0, 7));
let action = h.mgr.code_actions(doc, rng(0, 5, 0, 5), vec![]).await.unwrap().remove(0);
let action = h.mgr.resolve_code_action(host, action).await.unwrap();
let edit = action.edit.unwrap();
assert_eq!(edit.files.len(), 1, "resource operations on virtual paths are dropped");
assert!(
matches!(&edit.files[0],FileEdit::Edit {path:p,edits,..} if p.0 == path && edits[0].range == rng(0,4,0,7))
);
let command = action.command.unwrap();
assert!(h.mgr.provides_command(host, Some(action.server), &command.command).await);
assert!(
h.mgr
.resolve_command(host, action.server, command.clone(), vec![], Default::default())
.await
.unwrap()
.is_none()
);
h.mgr.execute_action_command(host, action.server, command, vec![], Default::default()).await.unwrap();
eventually(|| state.docs.lock().len() == 2).await;
assert!(state.docs.lock().values().any(|t| t == " one "));
assert!(state.docs.lock().values().any(|t| t == " two "));
let virtual_uris: Vec<_> = state.docs.lock().iter().map(|(u, t)| (u.clone(), t.clone())).collect();
for (uri, text) in &virtual_uris {
let col = if text.contains("one") { 4 } else { 12 };
state.peer().notify("textDocument/publishDiagnostics", json!({"uri":uri,"version":1,"diagnostics":[
{"range":{"start":{"line":0,"character":col},"end":{"line":0,"character":col+3}},"message":"embedded"},
{"range":{"start":{"line":0,"character":0},"end":{"line":0,"character":1}},"message":"outside"}
]})).unwrap();
}
let diagnostics = || {
h.events
.lock()
.iter()
.rev()
.find_map(|e| match e {
IdeEvent::Diagnostics { path: p, source, items } if p.0 == path && source == "embedded" => {
Some(items.clone())
}
_ => None,
})
.unwrap_or_default()
};
eventually(|| diagnostics().len() == 2).await;
assert!(diagnostics().iter().all(|d| d.source.as_deref() == Some("embedded")));
h.mgr.did_change(host, 2, &[AppliedChange { range: rng(0, 4, 0, 7), text: "ONE".into() }], &|| {
"😀[[ONE]] [[two]]".into()
});
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), host, "stale ranges cannot route");
h.mgr.refresh_injections(host).await.unwrap();
assert_eq!(h.mgr.injected_document(host, Pos::new(0, 5)), doc);
eventually(|| state.docs.lock().values().any(|t| t == " ONE ")).await;
assert!(h.mgr.resolve_completion(host, resolved).await.is_err(), "old completions cannot resolve after edits");
h.mgr.set_injections(host, 2, vec![]);
eventually(|| diagnostics().is_empty()).await;
assert!(h.events.lock().iter().all(
|e| !matches!(e,IdeEvent::Diagnostics {path,..} if path.0.to_string_lossy().contains(".openidea-injected-"))
));
h.mgr.did_close(host);
eventually(|| state.docs.lock().is_empty()).await;
h.mgr.shutdown_all().await;
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_injected_server_uses_the_normal_single_install_notification() {
let h = harness_for("sql", "sql", &[("embedded", incremental_caps())], &[]);
h.launcher.state("embedded").missing.store(true, Ordering::SeqCst);
let host = DocId::next();
let path = h.root.join("host.java");
h.mgr.did_open(host, &path, &LanguageId::plain_text(), 1, "sql");
let ranges = || vec![ide_lsp::InjectedRange { range: rng(0, 0, 0, 3), language: LanguageId::new("sql") }];
h.mgr.set_injections(host, 1, ranges());
h.wait_status("embedded", ServerStatus::Missing).await;
for version in 2..10 {
h.mgr.did_change(host, version, &[], &|| "sql".into());
h.mgr.set_injections(host, version, ranges());
}
assert_eq!(h.mgr.servers().len(), 1);
assert_eq!(h.statuses("embedded").iter().filter(|s| **s == ServerStatus::Missing).count(), 1);
h.mgr.did_close(host);
h.mgr.shutdown_all().await;
}
+1 -1
Переглянути файл
@@ -1 +1 @@
f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e
446ff7b14c2d5b0fca8a3febe489906e9d56fc5b66878c953887e16dd54f5d5a
+1 -1
Переглянути файл
@@ -1 +1 @@
f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c
c55acafff3dbe090aac50873d957e73add4db50b4ed3af0def7ec77c1cd1a9d5
+1 -1
Переглянути файл
@@ -1 +1 @@
7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9
543e921a71667b481e9d7d3b6cf86bc46113ac7068a902d93063eff85c2c812d
+1 -1
Переглянути файл
@@ -1 +1 @@
f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a
fa9ccf5c4f999031e1fb94b9e42b6caddfd5e5aeb08181660f51488711326550
+1 -1
Переглянути файл
@@ -1 +1 @@
f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb
69fca6e054ccf188b7cfe23081470676bfbee56f96b0247c7ff67aba93a24d39
+1 -1
Переглянути файл
@@ -1 +1 @@
a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2
cc2fdbb7953bac992375701812e8c4cf666652e027e8b6ecc68b43e0ae7d7ae1
+1 -1
Переглянути файл
@@ -1 +1 @@
4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f
4e146d6caa1cc114d33ae18f396ac8da4d6391477daa32cc69e6feca087cf20a
+1 -1
Переглянути файл
@@ -1 +1 @@
110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898
cfb6e388588d01fd3f772302049e673729138717ce7b761dfcecc4ee213a8d98
+1 -1
Переглянути файл
@@ -1 +1 @@
2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436
6117ebfa5220ab04f03884d589140426d2ee693a23f058e2457952f30314099d
+1 -1
Переглянути файл
@@ -1 +1 @@
6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97
e746d420d5f357e00b523e6e118d283df08e7b5f09f59b672645721a02f6ca46
+1 -1
Переглянути файл
@@ -1 +1 @@
a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863
e127b6562e398cdb2d9d748434315ae78e61cfdfa3f1859da2e6eaeb2144030d
+1 -1
Переглянути файл
@@ -1 +1 @@
f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0
6459f7899464237399e33f4190a7526f05a010d6384a36f42c25195202fb6c58
+1 -1
Переглянути файл
@@ -1 +1 @@
50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41
15236a23798998a0e1669c0ce657effb88481ef7236f5738519601e23b07dd1f
+1 -1
Переглянути файл
@@ -1 +1 @@
b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161
8af4f2658b074aebdabefbe49d0641da767c109b107ecf182bcb8cb937092b82
+1 -1
Переглянути файл
@@ -1 +1 @@
b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321
57a4527c74f62fb98ba388223863d69afdfc9f75b6027af65658f5909379e3c9
+1 -1
Переглянути файл
@@ -1 +1 @@
91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10
c2a29a1982c278a21a772e440662817b5f096b4740ac99539c5ab2721251acf8
+1 -1
Переглянути файл
@@ -1 +1 @@
3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c
634b3da9468c13d5a74a90d187f6c510b28abe4684e6072db630b976f331e899
+1 -1
Переглянути файл
@@ -1 +1 @@
83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd
b9d5c9b532e022bd4f43ecb80539efd6cab628ecba05c1ecb79d4ba677c1c8c0
+1 -1
Переглянути файл
@@ -1 +1 @@
557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08
787ab4dbe4ef4ee8cd9af98079ae6332aeddacaa3f66fc1b3327f35e76f7ca01
+1 -1
Переглянути файл
@@ -1 +1 @@
884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5
f0771c02543e5c96ae6a4f38402b666ef06d22abc1157dbc963c7fb2baee0221
+1 -1
Переглянути файл
@@ -1 +1 @@
5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59
86bd9d7f47e9dd9463bcef0173cd24bac4d83e9172adef3e620acc4abc6bc620
+1 -1
Переглянути файл
@@ -1 +1 @@
253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0
d560db74cda33ff86e7a5329df8f6cd8675f3df0288e427325e0777778f03f44
+1 -1
Переглянути файл
@@ -1 +1 @@
93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d
20b3cfd84d9129dd74b56c8766766fe714df7a5e6e051dc72c379d5fdb59b127
+1 -1
Переглянути файл
@@ -1 +1 @@
62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391
d30ffd74e9e444f51024586f9c67cefede44ad89bd251410ea3881bd2255b1d5
+1 -1
Переглянути файл
@@ -1 +1 @@
4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e
9a354ee52f1ee1080ce9c687e9fc59e137da8f38c6723316b8aeecb5bdf1e4ad
+1 -1
Переглянути файл
@@ -1 +1 @@
3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b
ae183781f1fc1639fa7b1b3c4b8ff100e4e25daab69bbf876584050e5363473f
+1 -1
Переглянути файл
@@ -1 +1 @@
70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5
8a8c92661df8d693d5eabc45946e2d98c9e887090e4233e65e896421c655dcdd
+26 -3
Переглянути файл
@@ -393,7 +393,7 @@ working. New in 1.4:
- [Language features](#additional-language-features-api-14): formatting,
signature help, highlights, links, code lenses, selection ranges, folds,
colors/presentations, symbols, implementation, type definition and gutter
actions; [injections](#embedded-languages-api-14) route plugin providers to
actions; [injections](#embedded-languages-api-14) route providers and language servers to
embedded virtual documents.
- [Snippets](#snippets-api-14) and [menus](#additional-menu-locations-api-14):
numbered placeholders and editor title, main toolbar, Problems and search
@@ -1119,8 +1119,31 @@ At most 1,000 non-overlapping injection ranges and 32 embedded languages
are routed per document. Earlier ranges in document order win overlaps.
Documents over 2 MiB are excluded. Invalid ranges, self-injections and invalid
language ids are ignored. Injections are not applied recursively. The same
server-side plugin host handles remote editing. **Embedded LSP forwarding is
future host-side work**, not part of 1.4, and needs no WIT change.
server-side plugin host handles remote editing.
When the embedded language has configured language servers, the host also
routes completion (including resolve), hover, signature help, definition,
declaration, type definition, implementation, references, document highlights
and code actions to those servers inside the range. Each range gets its own
masked document, even when several ranges share a language. At most 32 LSP
documents are kept per host document; the same 2 MiB document limit applies.
These documents use non-existent sibling paths with the embedded language's
extension, so servers can discover project configuration. They are never
written to disk. Servers start through the usual settings, project trust and
installation flow; a missing server does not prompt again on each edit.
Edits invalidate old ranges immediately. The host refreshes them after the
300 ms debounce, or before a position request, synchronizes retained documents
with `didChange`, and closes documents when their range disappears, the plugin
is disabled or the host document closes. Results into these documents map back
to the host path and are clipped to the range; locations in other files pass
through. Replacement edits crossing a boundary and resource operations on
virtual paths are dropped. Completion and code-action resolve keep their
originating range/server, and stale results cannot resolve after an edit.
Diagnostics are restricted to each range, labelled with the embedded server's
name and merged with that server's other diagnostics for the host file. They
clear when the range disappears. Formatting and rename use the host server.
Remote projects use this same host-side LSP lifecycle and routing.
### Snippets (API 1.4)
+1 -1
Переглянути файл
@@ -1 +1 @@
c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d
34bea4bf36b7a814dafe02aaee02fe3354a667d1c8ca66f238de00322d0a035a
+1 -1
Переглянути файл
@@ -1 +1 @@
0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5
fdd68915997803652ad6f5c549107b043b9001ad4e176d2473d9b37469fbe1cc
+1 -1
Переглянути файл
@@ -1 +1 @@
4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6
ad3fe934d30b48d53097bf4dee36a091b2410c0325924702c66e717405b58203
+1 -1
Переглянути файл
@@ -1 +1 @@
c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65
5710601823307d167805a7ca20e7498fb9c37082b20df7fd1f86e1cd66b438f3
+1 -1
Переглянути файл
@@ -1 +1 @@
2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6
1a486ceda9986bc253a0098c983f755c8860e6b7a3f2d7f0284b3b928b4e6c9e
+1 -1
Переглянути файл
@@ -1 +1 @@
b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0
13f27c819384f92e406bf7226a46d3fe5b063e850db3d64916e661e1a63f345a
+1 -1
Переглянути файл
@@ -1 +1 @@
849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4
c7b2b471f26201c037522ee4a5b086d1c8ef88fbbb3f11f7279ad1a7185bdbae
+1 -1
Переглянути файл
@@ -1 +1 @@
ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c
82c71af4a1f771dbd16a4c8f71b9bd6a6a53d82bd92958d4862467237b82d228
+1 -1
Переглянути файл
@@ -1 +1 @@
0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397
65096a9b6331af9d40049801547e99e2fa1d5ae0f1065f6931af0aee8d2f24a7
+1 -1
Переглянути файл
@@ -1 +1 @@
dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411
cf8d21da412fd64f1f42c72b13be762ff1bcd43dd19965fc1fce805c5e1012ae
+1 -1
Переглянути файл
@@ -1 +1 @@
2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b
1ea1d7116e6b943e42580e43cb13199b6e48fc5acdf9784c4864adb7433ebb59
+1 -1
Переглянути файл
@@ -1 +1 @@
604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db
05ff46a6c1af09b84f9a931d8956d79fbad9507dcff708ba021e074c55c062e2
+1 -1
Переглянути файл
@@ -1 +1 @@
9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5
26e57f5294b40bc31d9283843b2fcf6ad09d475fd160d61ddd3d420e54880a24
+1 -1
Переглянути файл
@@ -1 +1 @@
5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4
0d2c1140fe718d06e08af903ee94bfe87cd04de77c1299f6f5c337042c6f6197
+1 -1
Переглянути файл
@@ -1 +1 @@
677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac
f4251a1cad1d5cea54559baca12d84fdf175e65db85ee3f4d29e381e045c40fb
+5 -1
Переглянути файл
@@ -1,6 +1,10 @@
package openidea:[email protected];
/// Embedded languages. This host routes plugin providers, not embedded LSP servers.
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
/// Each LSP range gets its own masked, in-memory sibling document (at most 32
/// per host document, documents over 2 MiB excluded). No file is written.
/// Formatting and rename continue to use the host language's server.
interface injections {
/// A versioned document snapshot. Coordinates are zero-based UTF-16.
record document { path: string, language: string, version: u64, text: string }
+5 -1
Переглянути файл
@@ -1,6 +1,10 @@
package openidea:[email protected];
/// Embedded languages. This host routes plugin providers, not embedded LSP servers.
/// Embedded languages. The host routes plugin providers and configured language
/// servers inside these ranges, preserving host-document UTF-16 coordinates.
/// Each LSP range gets its own masked, in-memory sibling document (at most 32
/// per host document, documents over 2 MiB excluded). No file is written.
/// Formatting and rename continue to use the host language's server.
interface injections {
/// A versioned document snapshot. Coordinates are zero-based UTF-16.
record document { path: string, language: string, version: u64, text: string }