diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs index 9533fc464..b2a1faca4 100644 --- a/crates/ide-host/src/ops/lsp.rs +++ b/crates/ide-host/src/ops/lsp.rs @@ -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 { + 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, doc: DocId, @@ -41,8 +69,8 @@ pub async fn lsp_completion( trigger: Option, ) -> IdeResult { 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> { 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> { - 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> { - 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, ) -> IdeResult>> { 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::>::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>, ) -> IdeResult> { 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 { 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> { - 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 { - 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 diff --git a/crates/ide-host/src/plugins/injections.rs b/crates/ide-host/src/plugins/injections.rs index 79e132845..82ff4f86e 100644 --- a/crates/ide-host/src/plugins/injections.rs +++ b/crates/ide-host/src/plugins/injections.rs @@ -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>> = + 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::>() + }; + for project in projects { + project.injection_providers_changed(); + } +} + +pub(crate) fn configure_lsp(lsp: &Arc) { + 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::*; diff --git a/crates/ide-host/src/plugins/navigation.rs b/crates/ide-host/src/plugins/navigation.rs index 9e8449c28..8278a4368 100644 --- a/crates/ide-host/src/plugins/navigation.rs +++ b/crates/ide-host/src/plugins/navigation.rs @@ -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; diff --git a/crates/ide-host/src/plugins/providers.rs b/crates/ide-host/src/plugins/providers.rs index 10a8ebf27..5bd0b7ce1 100644 --- a/crates/ide-host/src/plugins/providers.rs +++ b/crates/ide-host/src/plugins/providers.rs @@ -76,16 +76,29 @@ pub struct Providers { impl Providers { fn add(self: &Arc, 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) { + 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 { self.matching::>(kind, language).into_iter().map(|(plugin, _)| plugin).collect() @@ -260,6 +273,7 @@ static EDITOR_PROVIDERS: RwLock>> = RwLock::new(None); /// definition and inline completion (`None` removes them). pub fn set_editor_providers(host: Option<&Arc>) { *EDITOR_PROVIDERS.write() = host.map(Arc::downgrade); + super::injections::providers_changed(); } /// The plugin host whose providers take part, when any declares `kind`. diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index ebcfcb2ac..6de60ad9f 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -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); diff --git a/crates/ide-host/tests/plugin_language_features.rs b/crates/ide-host/tests/plugin_language_features.rs index 200b44740..c12f09712 100644 --- a/crates/ide-host/tests/plugin_language_features.rs +++ b/crates/ide-host/tests/plugin_language_features.rs @@ -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>, + requests: Mutex>, + } + 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 { + 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::::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(); + } +} diff --git a/crates/ide-lsp/src/convert.rs b/crates/ide-lsp/src/convert.rs index 1071b6957..7c21b9a8d 100644 --- a/crates/ide-lsp/src/convert.rs +++ b/crates/ide-lsp/src/convert.rs @@ -17,15 +17,30 @@ pub type Lookup<'a> = Box Option> + 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 Option + Send + Sync + 'a>; +type MapEdit<'a> = Box; + pub struct Conv<'a> { pub enc: Encoding, + map_location: Option>, + map_edit: Option>, lookup: Lookup<'a>, cache: Mutex>>>, } impl<'a> Conv<'a> { pub fn new(enc: Encoding, lookup: impl Fn(&Path) -> Option> + 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 + 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 { 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 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 }) diff --git a/crates/ide-lsp/src/handler.rs b/crates/ide-lsp/src/handler.rs index 20fae6b01..e54a264b4 100644 --- a/crates/ide-lsp/src/handler.rs +++ b/crates/ide-lsp/src/handler.rs @@ -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)) diff --git a/crates/ide-lsp/src/injections.rs b/crates/ide-lsp/src/injections.rs new file mode 100644 index 000000000..4f67a9e4b --- /dev/null +++ b/crates/ide-lsp/src/injections.rs @@ -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 + 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>>, + documents: RwLock>, + refreshed: Mutex>, + 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 { + 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) { + *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) { + self.set_injections_at(host, version, ranges, self.injection_generation()); + } + + pub fn set_injections_at(&self, host: DocId, version: u32, mut ranges: Vec, 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 { + 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) -> 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 { + 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 { + 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) -> 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) { + 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)> { + 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) { + 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) -> IdeResult { + 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, + ) -> Option<(PathBuf, Vec)> { + 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, + _: Box, + ) -> Result { + Err(crate::LaunchError::Missing) + } + } + + fn manager() -> (Arc, tempfile::TempDir, Arc>>) { + 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::>().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::>(), ["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); + } +} diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index 7b8dda66e..da3be48a2 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -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>, /// Contents fetched for virtual URIs. virtual_docs: Mutex>, + injections: injections::State, } /// Fetched virtual documents kept before the cache is reset. @@ -361,12 +364,23 @@ impl LspManager { sink: SharedSink, launcher: Arc, ) -> Arc { - Arc::new_cyclic(|me| Self { + Arc::::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::() 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 { + let params = serde_json::to_value(params).map_err(|e| IdeError::internal(e.to_string()))?; + let stamp = self.injected_request_stamp(¶ms); let fut = async { if t.server.name == "csharp-ls" { - let mut value = t.run.peer.request::(method, params, cancel.cloned()).await?; + let mut value = t.run.peer.request::(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::(method, params, cancel.cloned()).await + t.run.peer.request::(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 { + pub async fn resolve_completion(&self, doc: DocId, mut item: CompletionItem) -> IdeResult { + 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 { + pub async fn resolve_code_action(&self, doc: DocId, mut action: CodeAction) -> IdeResult { + 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> = 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, 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, cancel: CancellationToken, ) -> IdeResult> { + 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, cancel: CancellationToken, ) -> IdeResult> { + 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> = 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); } } } diff --git a/crates/ide-lsp/src/pull.rs b/crates/ide-lsp/src/pull.rs index a8a75bafc..b82a8492a 100644 --- a/crates/ide-lsp/src/pull.rs +++ b/crates/ide-lsp/src/pull.rs @@ -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, 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 }); + } } } diff --git a/crates/ide-lsp/src/text.rs b/crates/ide-lsp/src/text.rs index cfe5e23b9..757572b8c 100644 --- a/crates/ide-lsp/src/text.rs +++ b/crates/ide-lsp/src/text.rs @@ -50,7 +50,6 @@ impl DocText { &self.text } - #[cfg(test)] pub fn line_count(&self) -> usize { self.lines.len() } diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 05a866391..dd2a05337 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -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; +} diff --git a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources index 01b6e7050..e21331217 100644 --- a/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/api14/dist/plugin.wasm.sources @@ -1 +1 @@ -f8c5b18f9734fd19f65a32e5087655d75ad944dddda31780f61672b94da3da6e +446ff7b14c2d5b0fca8a3febe489906e9d56fc5b66878c953887e16dd54f5d5a diff --git a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources index 3b08c7837..ad3169e34 100644 --- a/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/commit/dist/plugin.wasm.sources @@ -1 +1 @@ -f4ae5c053230e2a805b097d79392501ad4654f715d6d194937299273b67fda7c +c55acafff3dbe090aac50873d957e73add4db50b4ed3af0def7ec77c1cd1a9d5 diff --git a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources index 51acc097d..c98371c10 100644 --- a/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/data-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -7631939096b867c4707f7e0f929d2b75f91ef2b76da764d6e21233a5c06e8af9 +543e921a71667b481e9d7d3b6cf86bc46113ac7068a902d93063eff85c2c812d diff --git a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources index e5b55e534..52f9a6ac7 100644 --- a/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/detail-views/dist/plugin.wasm.sources @@ -1 +1 @@ -f5a2d43df242aa0ba9f358d9abf7296fd6a56b163bb526e62ce9c8b4d17e4b9a +fa9ccf5c4f999031e1fb94b9e42b6caddfd5e5aeb08181660f51488711326550 diff --git a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources index 01bab8cfe..0fb5db03c 100644 --- a/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/diagnostics/dist/plugin.wasm.sources @@ -1 +1 @@ -f19481ef89309e6fc65736cb18018a4bf9b679e5a1c6c8e9f92adf39578ca4cb +69fca6e054ccf188b7cfe23081470676bfbee56f96b0247c7ff67aba93a24d39 diff --git a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources index 91cd73637..3da3fc1ef 100644 --- a/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/endpoints/dist/plugin.wasm.sources @@ -1 +1 @@ -a6ba20c5bd531d965e64041b05f1dabfb1634c3192b06cdb61578bd2bf6f1af2 +cc2fdbb7953bac992375701812e8c4cf666652e027e8b6ecc68b43e0ae7d7ae1 diff --git a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources index 8d00ff0c6..7c56338bd 100644 --- a/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/execution/dist/plugin.wasm.sources @@ -1 +1 @@ -4c20506bb51bb9c08ebedb3ffb0c89ef153ee9565a466ac99d941a2c83ac2c9f +4e146d6caa1cc114d33ae18f396ac8da4d6391477daa32cc69e6feca087cf20a diff --git a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources index 2b7f857ab..1ef247c14 100644 --- a/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/file-templates/dist/plugin.wasm.sources @@ -1 +1 @@ -110bbe7e4c0bd02d93c0c7ccae8fcb1eccbcf355587ca271d8046b588f3db898 +cfb6e388588d01fd3f772302049e673729138717ce7b761dfcecc4ee213a8d98 diff --git a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources index eb8523fe1..256966493 100644 --- a/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/files-events/dist/plugin.wasm.sources @@ -1 +1 @@ -2c02b56def836148a1be5ac0429394d9430d468c3d3ee3682a3c742bd8c90436 +6117ebfa5220ab04f03884d589140426d2ee693a23f058e2457952f30314099d diff --git a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources index d0e5c3855..651736f72 100644 --- a/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/grouped-views/dist/plugin.wasm.sources @@ -1 +1 @@ -6681327fc078ecad751627998c4b6299b6176484a61760d6f265b41bd3f42c97 +e746d420d5f357e00b523e6e118d283df08e7b5f09f59b672645721a02f6ca46 diff --git a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources index 368e33562..88330732d 100644 --- a/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hello/dist/plugin.wasm.sources @@ -1 +1 @@ -a6a21264dffff392ea5e998c4545cdbe21a08bae429d2765e6bfeac9f2c9f863 +e127b6562e398cdb2d9d748434315ae78e61cfdfa3f1859da2e6eaeb2144030d diff --git a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources index 98d867b4c..4ac605dc8 100644 --- a/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/hover/dist/plugin.wasm.sources @@ -1 +1 @@ -f456deae0bbdee79c22d844ac8a9db5e4b867eaed245dc9332c1708e32e01ae0 +6459f7899464237399e33f4190a7526f05a010d6384a36f42c25195202fb6c58 diff --git a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources index b1df655aa..9d17287b1 100644 --- a/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/indexer/dist/plugin.wasm.sources @@ -1 +1 @@ -50e77e306f196dd43f7d95789cc1da94085a2863f1ccbec83701c34916ffff41 +15236a23798998a0e1669c0ce657effb88481ef7236f5738519601e23b07dd1f diff --git a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources index f8b5ce5a8..96e365a8e 100644 --- a/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/inlay-hints/dist/plugin.wasm.sources @@ -1 +1 @@ -b9342ba2bf0107bb65074aecf726991d326a45e50ac12100a97c7a6051b01161 +8af4f2658b074aebdabefbe49d0641da767c109b107ecf182bcb8cb937092b82 diff --git a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources index 2f602f48c..1a1ba5036 100644 --- a/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/jobs/dist/plugin.wasm.sources @@ -1 +1 @@ -b4300035602303343f8b1c15326f86a696bfe814a3f2c7e1e186f5eed5a92321 +57a4527c74f62fb98ba388223863d69afdfc9f75b6027af65658f5909379e3c9 diff --git a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources index 68dd9dedb..492d5a0d7 100644 --- a/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/language-features/dist/plugin.wasm.sources @@ -1 +1 @@ -91d6f1f11a26effda7a21fd423f8ea6f42e707168ac5080033e9116c89d11d10 +c2a29a1982c278a21a772e440662817b5f096b4740ac99539c5ab2721251acf8 diff --git a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources index 557220608..9e59d9c50 100644 --- a/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/misbehave/dist/plugin.wasm.sources @@ -1 +1 @@ -3edc6122e6cbba033d37f44d7855deff8473f097205846e06768888ac416742c +634b3da9468c13d5a74a90d187f6c510b28abe4684e6072db630b976f331e899 diff --git a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources index f42466716..bb6cb7f95 100644 --- a/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/path-events/dist/plugin.wasm.sources @@ -1 +1 @@ -83ce23d58d809e9a724dfcb8bec2cccc1f73c77324d39d1c6ee2a7c855d763fd +b9d5c9b532e022bd4f43ecb80539efd6cab628ecba05c1ecb79d4ba677c1c8c0 diff --git a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources index 8993d4acb..8800d454c 100644 --- a/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/private-dir/dist/plugin.wasm.sources @@ -1 +1 @@ -557e263c737bc5cdf15f5bc4ed4fa5d7a4f2e252b06c2eb873e936a968519c08 +787ab4dbe4ef4ee8cd9af98079ae6332aeddacaa3f66fc1b3327f35e76f7ca01 diff --git a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources index de9a6fc20..7adc4fa8f 100644 --- a/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/semantic-tokens/dist/plugin.wasm.sources @@ -1 +1 @@ -884e60babc875a5971a9ee42d0949c22712b9af749a6995d6273efa191aa65b5 +f0771c02543e5c96ae6a4f38402b666ef06d22abc1157dbc963c7fb2baee0221 diff --git a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources index e970f3e1d..dbd2f0260 100644 --- a/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/settings-pages/dist/plugin.wasm.sources @@ -1 +1 @@ -5f2f03bbc397dde8ad7c9b1cf2a61e9a4ae5881b34d71ec2e5748fc474754d59 +86bd9d7f47e9dd9463bcef0173cd24bac4d83e9172adef3e620acc4abc6bc620 diff --git a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources index 9d0cc6c35..d796c2ba3 100644 --- a/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/storage/dist/plugin.wasm.sources @@ -1 +1 @@ -253a7ab7bb1827dee5434b47c732e484976237ca5a5d637536ccc36905d84be0 +d560db74cda33ff86e7a5329df8f6cd8675f3df0288e427325e0777778f03f44 diff --git a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources index ab1331dc2..a8c6a1aaf 100644 --- a/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/symbols/dist/plugin.wasm.sources @@ -1 +1 @@ -93467ab4d2a02ec72820f17df873510707dfdabd02d70c19164b8192d967bf7d +20b3cfd84d9129dd74b56c8766766fe714df7a5e6e051dc72c379d5fdb59b127 diff --git a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources index 8421a84e7..5307b0e6a 100644 --- a/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/tables/dist/plugin.wasm.sources @@ -1 +1 @@ -62372338f5c0129177936ad34c7f0fc5d8cd2f14c3f895c026ca7f79f1e31391 +d30ffd74e9e444f51024586f9c67cefede44ad89bd251410ea3881bd2255b1d5 diff --git a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources index 8244b0f6f..1025ed3a0 100644 --- a/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-content/dist/plugin.wasm.sources @@ -1 +1 @@ -4d1952be87c87f44e23b2420f966e23912405ba842d9928f1f9dec409dfa670e +9a354ee52f1ee1080ce9c687e9fc59e137da8f38c6723316b8aeecb5bdf1e4ad diff --git a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources index 4e2868158..1a0ce43cb 100644 --- a/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-log/dist/plugin.wasm.sources @@ -1 +1 @@ -3992885be293901280938f5dc6498a39a310e1a3c7e34473394116a2327bb13b +ae183781f1fc1639fa7b1b3c4b8ff100e4e25daab69bbf876584050e5363473f diff --git a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources index 260ca0f32..aa0be7b21 100644 --- a/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources +++ b/crates/ide-plugin/tests/fixtures/web-pty/dist/plugin.wasm.sources @@ -1 +1 @@ -70926629540ab1277641aef53419ce3014f291e424502619378511735bdbb8d5 +8a8c92661df8d693d5eabc45946e2d98c9e887090e4233e65e896421c655dcdd diff --git a/plugins/README.md b/plugins/README.md index eafb63087..50098a029 100644 --- a/plugins/README.md +++ b/plugins/README.md @@ -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) diff --git a/plugins/official/ai-completion/dist/plugin.wasm.sources b/plugins/official/ai-completion/dist/plugin.wasm.sources index efc9a0eb8..91979bbf6 100644 --- a/plugins/official/ai-completion/dist/plugin.wasm.sources +++ b/plugins/official/ai-completion/dist/plugin.wasm.sources @@ -1 +1 @@ -c4900df449a6a323bab1f4fe5fe76ee42e2839c9197636b7c1cef733dffc683d +34bea4bf36b7a814dafe02aaee02fe3354a667d1c8ca66f238de00322d0a035a diff --git a/plugins/official/blame-annotations/dist/plugin.wasm.sources b/plugins/official/blame-annotations/dist/plugin.wasm.sources index 140444600..0f094100a 100644 --- a/plugins/official/blame-annotations/dist/plugin.wasm.sources +++ b/plugins/official/blame-annotations/dist/plugin.wasm.sources @@ -1 +1 @@ -0ea139d4a0d47072971c0b37b93085159e412554ffb45d19590bae4dc10f48b5 +fdd68915997803652ad6f5c549107b043b9001ad4e176d2473d9b37469fbe1cc diff --git a/plugins/official/checkstyle/dist/plugin.wasm.sources b/plugins/official/checkstyle/dist/plugin.wasm.sources index 287e7bd70..091b95fcd 100644 --- a/plugins/official/checkstyle/dist/plugin.wasm.sources +++ b/plugins/official/checkstyle/dist/plugin.wasm.sources @@ -1 +1 @@ -4e4bc361395f6364e17211a9d3c32232a370ea1ee72e5197eab5f706ea465dc6 +ad3fe934d30b48d53097bf4dee36a091b2410c0325924702c66e717405b58203 diff --git a/plugins/official/container-runner/dist/plugin.wasm.sources b/plugins/official/container-runner/dist/plugin.wasm.sources index 590301e25..651d6ae41 100644 --- a/plugins/official/container-runner/dist/plugin.wasm.sources +++ b/plugins/official/container-runner/dist/plugin.wasm.sources @@ -1 +1 @@ -c39211d416f45dff0265216509070ffdecc4e0f4e738c88176cad5faaca01b65 +5710601823307d167805a7ca20e7498fb9c37082b20df7fd1f86e1cd66b438f3 diff --git a/plugins/official/csv-editor/dist/plugin.wasm.sources b/plugins/official/csv-editor/dist/plugin.wasm.sources index e416f9c2f..6d7a806f3 100644 --- a/plugins/official/csv-editor/dist/plugin.wasm.sources +++ b/plugins/official/csv-editor/dist/plugin.wasm.sources @@ -1 +1 @@ -2bbb840e0bead664ccb467dbb299b269fc9a03ed4995c8d890ae195efe6e47a6 +1a486ceda9986bc253a0098c983f755c8860e6b7a3f2d7f0284b3b928b4e6c9e diff --git a/plugins/official/git-toolbox/dist/plugin.wasm.sources b/plugins/official/git-toolbox/dist/plugin.wasm.sources index 502c85232..702fcf51f 100644 --- a/plugins/official/git-toolbox/dist/plugin.wasm.sources +++ b/plugins/official/git-toolbox/dist/plugin.wasm.sources @@ -1 +1 @@ -b1fdf25b8307803b0e5e189bbe041cdb23b1e7b73abb4b76fd7a1b9ee48d87b0 +13f27c819384f92e406bf7226a46d3fe5b063e850db3d64916e661e1a63f345a diff --git a/plugins/official/graphviz/dist/plugin.wasm.sources b/plugins/official/graphviz/dist/plugin.wasm.sources index 1a52decfc..1f99a4425 100644 --- a/plugins/official/graphviz/dist/plugin.wasm.sources +++ b/plugins/official/graphviz/dist/plugin.wasm.sources @@ -1 +1 @@ -849d83a77d80e8d8a4f2c2801f4728300dd4504da411689834217b9973e582f4 +c7b2b471f26201c037522ee4a5b086d1c8ef88fbbb3f11f7279ad1a7185bdbae diff --git a/plugins/official/key-promoter/dist/plugin.wasm.sources b/plugins/official/key-promoter/dist/plugin.wasm.sources index 3fb581b42..90ab3f4dd 100644 --- a/plugins/official/key-promoter/dist/plugin.wasm.sources +++ b/plugins/official/key-promoter/dist/plugin.wasm.sources @@ -1 +1 @@ -ff42121519f6796db56a71a504484b149592e9e28ed33c783d2e6141cfe84d6c +82c71af4a1f771dbd16a4c8f71b9bd6a6a53d82bd92958d4862467237b82d228 diff --git a/plugins/official/license-header/dist/plugin.wasm.sources b/plugins/official/license-header/dist/plugin.wasm.sources index 563266688..ea3f9e602 100644 --- a/plugins/official/license-header/dist/plugin.wasm.sources +++ b/plugins/official/license-header/dist/plugin.wasm.sources @@ -1 +1 @@ -0d5ed3d81ab5496b92e7b012ae6e1e765fb45f4f6b0613a03f2bc79029ed9397 +65096a9b6331af9d40049801547e99e2fa1d5ae0f1065f6931af0aee8d2f24a7 diff --git a/plugins/official/mybatis/dist/plugin.wasm.sources b/plugins/official/mybatis/dist/plugin.wasm.sources index 95a0ea2d0..bc35fb9ec 100644 --- a/plugins/official/mybatis/dist/plugin.wasm.sources +++ b/plugins/official/mybatis/dist/plugin.wasm.sources @@ -1 +1 @@ -dd34327c65747b1b57eef0854161837bd4e2843a59efec7730b81d8a2866a411 +cf8d21da412fd64f1f42c72b13be762ff1bcd43dd19965fc1fce805c5e1012ae diff --git a/plugins/official/regex-tester/dist/plugin.wasm.sources b/plugins/official/regex-tester/dist/plugin.wasm.sources index b4be800a5..73dc03c73 100644 --- a/plugins/official/regex-tester/dist/plugin.wasm.sources +++ b/plugins/official/regex-tester/dist/plugin.wasm.sources @@ -1 +1 @@ -2141b215b141d82cc9a5cffa63c11e2b53e7bebcd31b93fb8d2db0ff5c2acd5b +1ea1d7116e6b943e42580e43cb13199b6e48fc5acdf9784c4864adb7433ebb59 diff --git a/plugins/official/solidity/dist/plugin.wasm.sources b/plugins/official/solidity/dist/plugin.wasm.sources index 60009d455..417492708 100644 --- a/plugins/official/solidity/dist/plugin.wasm.sources +++ b/plugins/official/solidity/dist/plugin.wasm.sources @@ -1 +1 @@ -604a289139233c13108ff8aab448e4278604e5260d7b2485e3ace3bad9e855db +05ff46a6c1af09b84f9a931d8956d79fbad9507dcff708ba021e074c55c062e2 diff --git a/plugins/official/string-tools/dist/plugin.wasm.sources b/plugins/official/string-tools/dist/plugin.wasm.sources index bea16abcf..3c1bb4b65 100644 --- a/plugins/official/string-tools/dist/plugin.wasm.sources +++ b/plugins/official/string-tools/dist/plugin.wasm.sources @@ -1 +1 @@ -9efa3a6a41c05e81a50893a2cff79da6e8e5f97219ddfd54f7ed201270934bb5 +26e57f5294b40bc31d9283843b2fcf6ad09d475fd160d61ddd3d420e54880a24 diff --git a/plugins/official/test-runner/dist/plugin.wasm.sources b/plugins/official/test-runner/dist/plugin.wasm.sources index 114cf23f1..f54affe63 100644 --- a/plugins/official/test-runner/dist/plugin.wasm.sources +++ b/plugins/official/test-runner/dist/plugin.wasm.sources @@ -1 +1 @@ -5f05e3696671258af5474b10abafd3cab48385493d5336061a9a8679307b56b4 +0d2c1140fe718d06e08af903ee94bfe87cd04de77c1299f6f5c337042c6f6197 diff --git a/plugins/official/todo-finder/dist/plugin.wasm.sources b/plugins/official/todo-finder/dist/plugin.wasm.sources index 99cf55807..86965003f 100644 --- a/plugins/official/todo-finder/dist/plugin.wasm.sources +++ b/plugins/official/todo-finder/dist/plugin.wasm.sources @@ -1 +1 @@ -677ec8005d29d4727afbc4b8fba6afcbc26996f0db461d1e98a6e9e52b5f7fac +f4251a1cad1d5cea54559baca12d84fdf175e65db85ee3f4d29e381e045c40fb diff --git a/sdk/rust/openidea-plugin/wit/injections.wit b/sdk/rust/openidea-plugin/wit/injections.wit index 5f9d7c2eb..016e5b905 100644 --- a/sdk/rust/openidea-plugin/wit/injections.wit +++ b/sdk/rust/openidea-plugin/wit/injections.wit @@ -1,6 +1,10 @@ package openidea:plugin@1.4.0; -/// 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 } diff --git a/wit/injections.wit b/wit/injections.wit index 5f9d7c2eb..016e5b905 100644 --- a/wit/injections.wit +++ b/wit/injections.wit @@ -1,6 +1,10 @@ package openidea:plugin@1.4.0; -/// 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 }