extension_host: Sync language extensions to remotes (#56487)

Resolves #56068

Remote extension sync previously only sent extensions that can run
remotely: language servers, debug adapters, and debug locators. That
excluded language-only extensions such as TOML, so a remote session
could have Tombi synced and loaded while the remote language registry
still lacked TOML's language definition.

This diff keeps the remote sync set narrow: it starts from extensions
that can run remotely, then adds the extension that defines each
language referenced by those extensions' language servers. This installs
the TOML language config alongside Tombi without syncing unrelated
language-only extensions, allowing project settings schemas to recognize
language names like TOML and allowing remote language servers to attach
to buffers whose language is provided by a separate extension.

Release Notes:

- Fixed remote sessions failing to recognize languages provided by
language-only extensions.

---------

Co-authored-by: Finn Evers <finn.evers@outlook.de>
This commit is contained in:
liam 2026-06-24 17:32:43 -04:00 committed by GitHub
parent ee571d3c69
commit 0b458e53a5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 257 additions and 16 deletions

View file

@ -181,9 +181,27 @@ impl ExtensionManifest {
}
pub fn allow_remote_load(&self) -> bool {
!self.language_servers.is_empty()
self.remote_load().is_some()
}
pub fn remote_load(&self) -> Option<RemoteLoad<'_>> {
(!self.language_servers.is_empty()
|| !self.debug_adapters.is_empty()
|| !self.debug_locators.is_empty()
|| !self.debug_locators.is_empty())
.then_some(RemoteLoad { manifest: self })
}
}
pub struct RemoteLoad<'a> {
manifest: &'a ExtensionManifest,
}
impl RemoteLoad<'_> {
pub fn language_dependencies(&self) -> impl Iterator<Item = LanguageName> + '_ {
self.manifest
.language_servers
.values()
.flat_map(|language_server_config| language_server_config.languages())
}
}
@ -345,7 +363,7 @@ pub struct SlashCommandManifestEntry {
pub requires_argument: bool,
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
#[derive(Clone, Default, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct DebugAdapterManifestEntry {
pub schema_path: Option<RelPathBuf>,
}
@ -484,7 +502,7 @@ mod tests {
#[test]
fn test_build_adapter_schema_path_without_schema_path() {
let adapter_name = Arc::from("my_adapter");
let entry = DebugAdapterManifestEntry { schema_path: None };
let entry = DebugAdapterManifestEntry::default();
let path = build_debug_adapter_schema_path(&adapter_name, &entry).unwrap();
assert_eq!(path, rel_path_buf("debug_adapter_schemas/my_adapter.json"));

View file

@ -11,7 +11,7 @@ use async_compression::futures::bufread::GzipDecoder;
use async_tar::Archive;
use client::{Client, proto, telemetry::Telemetry};
use cloud_api_types::{ExtensionMetadata, ExtensionProvides, GetExtensionsResponse};
use collections::{BTreeMap, BTreeSet, FxHashSet, HashSet, btree_map};
use collections::{BTreeMap, BTreeSet, FxHashSet, HashMap, HashSet, btree_map};
pub use extension::ExtensionManifest;
use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
use extension::{
@ -178,6 +178,55 @@ pub struct ExtensionIndex {
pub languages: BTreeMap<LanguageName, ExtensionIndexLanguageEntry>,
}
impl ExtensionIndex {
fn extensions_to_sync_to_remote(&self) -> RemoteSyncExtensions {
let mut extensions = RemoteSyncExtensions::default();
for (id, entry) in &self.extensions {
if entry.manifest.remote_load().is_some() {
extensions.insert_extension_and_language_dependencies(self, id);
}
}
extensions
}
}
#[derive(Default)]
struct RemoteSyncExtensions(HashMap<Arc<str>, ExtensionIndexEntry>);
impl RemoteSyncExtensions {
fn insert_extension_and_language_dependencies(
&mut self,
index: &ExtensionIndex,
id: &Arc<str>,
) {
if self.0.contains_key(id) {
return;
}
let Some(entry) = index.extensions.get(id) else {
return;
};
self.0.insert(id.clone(), entry.clone());
let Some(remote_load) = entry.manifest.remote_load() else {
return;
};
for language in remote_load.language_dependencies() {
if let Some(language_entry) = index.languages.get(&language) {
self.insert_extension_and_language_dependencies(index, &language_entry.extension);
}
}
}
fn into_entries(self) -> impl Iterator<Item = (Arc<str>, ExtensionIndexEntry)> {
self.0.into_iter()
}
}
#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)]
pub struct ExtensionIndexEntry {
pub manifest: Arc<ExtensionManifest>,
@ -1812,17 +1861,12 @@ impl ExtensionStore {
) -> Result<()> {
let extensions = this.update(cx, |this, _cx| {
this.extension_index
.extensions
.iter()
.filter_map(|(id, entry)| {
if !entry.manifest.allow_remote_load() {
return None;
}
Some(proto::Extension {
id: id.to_string(),
version: entry.manifest.version.to_string(),
dev: entry.dev,
})
.extensions_to_sync_to_remote()
.into_entries()
.map(|(id, entry)| proto::Extension {
id: id.to_string(),
version: entry.manifest.version.to_string(),
dev: entry.dev,
})
.collect()
})?;

View file

@ -34,6 +34,185 @@ fn init_logger() {
zlog::init_test();
}
fn remote_sync_entry(id: &str, manifest_body: &str) -> ExtensionIndexEntry {
let manifest = format!(
r#"
id = "{id}"
name = "{id}"
version = "1.0.0"
schema_version = 0
{manifest_body}
"#
);
ExtensionIndexEntry {
manifest: Arc::new(toml::from_str(&manifest).unwrap()),
dev: false,
}
}
fn remote_sync_language_entry(extension: &str, path: &str) -> ExtensionIndexLanguageEntry {
ExtensionIndexLanguageEntry {
extension: extension.into(),
path: path.into(),
matcher: LanguageMatcher::default(),
hidden: false,
grammar: None,
}
}
fn remote_sync_extension_ids(index: &ExtensionIndex) -> Vec<String> {
let mut extensions = index
.extensions_to_sync_to_remote()
.into_entries()
.map(|(id, _)| id.to_string())
.collect::<Vec<_>>();
extensions.sort();
extensions
}
#[test]
fn remote_sync_includes_language_dependencies() {
let index = ExtensionIndex {
extensions: [
(
"bar-language".into(),
remote_sync_entry("bar-language", r#"languages = ["languages/bar"]"#),
),
(
"foo-lsp".into(),
remote_sync_entry(
"foo-lsp",
r#"
[language_servers.foo]
language = "Foo"
"#,
),
),
(
"foo-language".into(),
remote_sync_entry("foo-language", r#"languages = ["languages/foo"]"#),
),
]
.into_iter()
.collect(),
languages: [
(
"Bar".into(),
remote_sync_language_entry("bar-language", "languages/bar"),
),
(
"Foo".into(),
remote_sync_language_entry("foo-language", "languages/foo"),
),
]
.into_iter()
.collect(),
themes: BTreeMap::default(),
icon_themes: BTreeMap::default(),
};
assert_eq!(
remote_sync_extension_ids(&index),
["foo-language", "foo-lsp"]
);
}
#[test]
fn remote_sync_keeps_shared_language_dependency_once() {
let index = ExtensionIndex {
extensions: [
(
"aaa-lsp".into(),
remote_sync_entry(
"aaa-lsp",
r#"
[language_servers.aaa]
language = "Foo"
"#,
),
),
(
"bbb-lsp".into(),
remote_sync_entry(
"bbb-lsp",
r#"
[language_servers.bbb]
language = "Foo"
"#,
),
),
(
"zzz-language".into(),
remote_sync_entry("zzz-language", r#"languages = ["languages/foo"]"#),
),
]
.into_iter()
.collect(),
languages: [(
"Foo".into(),
remote_sync_language_entry("zzz-language", "languages/foo"),
)]
.into_iter()
.collect(),
themes: BTreeMap::default(),
icon_themes: BTreeMap::default(),
};
assert_eq!(
remote_sync_extension_ids(&index),
["aaa-lsp", "bbb-lsp", "zzz-language"]
);
}
#[test]
fn remote_sync_keeps_remote_loadable_extensions_without_language_dependency() {
let index = ExtensionIndex {
extensions: [(
"foo".into(),
remote_sync_entry(
"foo",
r#"
[language_servers.foo]
language = "Foo"
"#,
),
)]
.into_iter()
.collect(),
languages: BTreeMap::default(),
themes: BTreeMap::default(),
icon_themes: BTreeMap::default(),
};
assert_eq!(remote_sync_extension_ids(&index), ["foo"]);
}
#[test]
fn remote_sync_keeps_debug_adapters() {
let index = ExtensionIndex {
extensions: [(
"foo".into(),
remote_sync_entry(
"foo",
r#"
[debug_adapters.foo]
"#,
),
)]
.into_iter()
.collect(),
languages: BTreeMap::default(),
themes: BTreeMap::default(),
icon_themes: BTreeMap::default(),
};
assert_eq!(remote_sync_extension_ids(&index), ["foo"]);
}
#[gpui::test]
async fn test_extension_store(cx: &mut TestAppContext) {
init_test(cx);