
In this PR, we've added two new methods that LSP extensions can call: * `shell_env()`, for retrieving the environment variables set in the user's default shell in the worktree * `which(command)`, for looking up paths to an executable (accounting for the user's shell env in the worktree) To test this out, we moved the `uiua` language support into an extension. We went ahead and removed the built-in support, since this language is extremely obscure. Sorry @mikayla-maki. To continue coding in Uiua in Zed, for now you can `Add Dev Extension` from the extensions pane, and select the `extensions/uiua` directory in the Zed repo. Very soon, we'll support publishing these extensions so that you'll be able to just install it normally. Release Notes: - N/A --------- Co-authored-by: Marshall <marshall@zed.dev>
661 lines
23 KiB
Rust
661 lines
23 KiB
Rust
use crate::{
|
|
ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionIndexThemeEntry,
|
|
ExtensionManifest, ExtensionStore, GrammarManifestEntry, RELOAD_DEBOUNCE_DURATION,
|
|
};
|
|
use async_compression::futures::bufread::GzipEncoder;
|
|
use collections::BTreeMap;
|
|
use fs::{FakeFs, Fs, RealFs};
|
|
use futures::{io::BufReader, AsyncReadExt, StreamExt};
|
|
use gpui::{Context, TestAppContext};
|
|
use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
|
|
use node_runtime::FakeNodeRuntime;
|
|
use parking_lot::Mutex;
|
|
use project::Project;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use std::{
|
|
ffi::OsString,
|
|
path::{Path, PathBuf},
|
|
sync::Arc,
|
|
};
|
|
use theme::ThemeRegistry;
|
|
use util::{
|
|
http::{FakeHttpClient, Response},
|
|
test::temp_tree,
|
|
};
|
|
|
|
#[cfg(test)]
|
|
#[ctor::ctor]
|
|
fn init_logger() {
|
|
if std::env::var("RUST_LOG").is_ok() {
|
|
env_logger::init();
|
|
}
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_extension_store(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
});
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
let http_client = FakeHttpClient::with_200_response();
|
|
|
|
fs.insert_tree(
|
|
"/the-extension-dir",
|
|
json!({
|
|
"installed": {
|
|
"zed-monokai": {
|
|
"extension.json": r#"{
|
|
"id": "zed-monokai",
|
|
"name": "Zed Monokai",
|
|
"version": "2.0.0",
|
|
"themes": {
|
|
"Monokai Dark": "themes/monokai.json",
|
|
"Monokai Light": "themes/monokai.json",
|
|
"Monokai Pro Dark": "themes/monokai-pro.json",
|
|
"Monokai Pro Light": "themes/monokai-pro.json"
|
|
}
|
|
}"#,
|
|
"themes": {
|
|
"monokai.json": r#"{
|
|
"name": "Monokai",
|
|
"author": "Someone",
|
|
"themes": [
|
|
{
|
|
"name": "Monokai Dark",
|
|
"appearance": "dark",
|
|
"style": {}
|
|
},
|
|
{
|
|
"name": "Monokai Light",
|
|
"appearance": "light",
|
|
"style": {}
|
|
}
|
|
]
|
|
}"#,
|
|
"monokai-pro.json": r#"{
|
|
"name": "Monokai Pro",
|
|
"author": "Someone",
|
|
"themes": [
|
|
{
|
|
"name": "Monokai Pro Dark",
|
|
"appearance": "dark",
|
|
"style": {}
|
|
},
|
|
{
|
|
"name": "Monokai Pro Light",
|
|
"appearance": "light",
|
|
"style": {}
|
|
}
|
|
]
|
|
}"#,
|
|
}
|
|
},
|
|
"zed-ruby": {
|
|
"extension.json": r#"{
|
|
"id": "zed-ruby",
|
|
"name": "Zed Ruby",
|
|
"version": "1.0.0",
|
|
"grammars": {
|
|
"ruby": "grammars/ruby.wasm",
|
|
"embedded_template": "grammars/embedded_template.wasm"
|
|
},
|
|
"languages": {
|
|
"ruby": "languages/ruby",
|
|
"erb": "languages/erb"
|
|
}
|
|
}"#,
|
|
"grammars": {
|
|
"ruby.wasm": "",
|
|
"embedded_template.wasm": "",
|
|
},
|
|
"languages": {
|
|
"ruby": {
|
|
"config.toml": r#"
|
|
name = "Ruby"
|
|
grammar = "ruby"
|
|
path_suffixes = ["rb"]
|
|
"#,
|
|
"highlights.scm": "",
|
|
},
|
|
"erb": {
|
|
"config.toml": r#"
|
|
name = "ERB"
|
|
grammar = "embedded_template"
|
|
path_suffixes = ["erb"]
|
|
"#,
|
|
"highlights.scm": "",
|
|
}
|
|
},
|
|
}
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let mut expected_index = ExtensionIndex {
|
|
extensions: [
|
|
(
|
|
"zed-ruby".into(),
|
|
ExtensionIndexEntry {
|
|
manifest: Arc::new(ExtensionManifest {
|
|
id: "zed-ruby".into(),
|
|
name: "Zed Ruby".into(),
|
|
version: "1.0.0".into(),
|
|
description: None,
|
|
authors: Vec::new(),
|
|
repository: None,
|
|
themes: Default::default(),
|
|
lib: Default::default(),
|
|
languages: vec!["languages/erb".into(), "languages/ruby".into()],
|
|
grammars: [
|
|
("embedded_template".into(), GrammarManifestEntry::default()),
|
|
("ruby".into(), GrammarManifestEntry::default()),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
language_servers: BTreeMap::default(),
|
|
}),
|
|
dev: false,
|
|
},
|
|
),
|
|
(
|
|
"zed-monokai".into(),
|
|
ExtensionIndexEntry {
|
|
manifest: Arc::new(ExtensionManifest {
|
|
id: "zed-monokai".into(),
|
|
name: "Zed Monokai".into(),
|
|
version: "2.0.0".into(),
|
|
description: None,
|
|
authors: vec![],
|
|
repository: None,
|
|
themes: vec![
|
|
"themes/monokai-pro.json".into(),
|
|
"themes/monokai.json".into(),
|
|
],
|
|
lib: Default::default(),
|
|
languages: Default::default(),
|
|
grammars: BTreeMap::default(),
|
|
language_servers: BTreeMap::default(),
|
|
}),
|
|
dev: false,
|
|
},
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
languages: [
|
|
(
|
|
"ERB".into(),
|
|
ExtensionIndexLanguageEntry {
|
|
extension: "zed-ruby".into(),
|
|
path: "languages/erb".into(),
|
|
grammar: Some("embedded_template".into()),
|
|
matcher: LanguageMatcher {
|
|
path_suffixes: vec!["erb".into()],
|
|
first_line_pattern: None,
|
|
},
|
|
},
|
|
),
|
|
(
|
|
"Ruby".into(),
|
|
ExtensionIndexLanguageEntry {
|
|
extension: "zed-ruby".into(),
|
|
path: "languages/ruby".into(),
|
|
grammar: Some("ruby".into()),
|
|
matcher: LanguageMatcher {
|
|
path_suffixes: vec!["rb".into()],
|
|
first_line_pattern: None,
|
|
},
|
|
},
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
themes: [
|
|
(
|
|
"Monokai Dark".into(),
|
|
ExtensionIndexThemeEntry {
|
|
extension: "zed-monokai".into(),
|
|
path: "themes/monokai.json".into(),
|
|
},
|
|
),
|
|
(
|
|
"Monokai Light".into(),
|
|
ExtensionIndexThemeEntry {
|
|
extension: "zed-monokai".into(),
|
|
path: "themes/monokai.json".into(),
|
|
},
|
|
),
|
|
(
|
|
"Monokai Pro Dark".into(),
|
|
ExtensionIndexThemeEntry {
|
|
extension: "zed-monokai".into(),
|
|
path: "themes/monokai-pro.json".into(),
|
|
},
|
|
),
|
|
(
|
|
"Monokai Pro Light".into(),
|
|
ExtensionIndexThemeEntry {
|
|
extension: "zed-monokai".into(),
|
|
path: "themes/monokai-pro.json".into(),
|
|
},
|
|
),
|
|
]
|
|
.into_iter()
|
|
.collect(),
|
|
};
|
|
|
|
let language_registry = Arc::new(LanguageRegistry::test());
|
|
let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
|
|
let node_runtime = FakeNodeRuntime::new();
|
|
|
|
let store = cx.new_model(|cx| {
|
|
ExtensionStore::new(
|
|
PathBuf::from("/the-extension-dir"),
|
|
None,
|
|
fs.clone(),
|
|
http_client.clone(),
|
|
node_runtime.clone(),
|
|
language_registry.clone(),
|
|
theme_registry.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
|
|
store.read_with(cx, |store, _| {
|
|
let index = &store.extension_index;
|
|
assert_eq!(index.extensions, expected_index.extensions);
|
|
assert_eq!(index.languages, expected_index.languages);
|
|
assert_eq!(index.themes, expected_index.themes);
|
|
|
|
assert_eq!(
|
|
language_registry.language_names(),
|
|
["ERB", "Plain Text", "Ruby"]
|
|
);
|
|
assert_eq!(
|
|
theme_registry.list_names(false),
|
|
[
|
|
"Monokai Dark",
|
|
"Monokai Light",
|
|
"Monokai Pro Dark",
|
|
"Monokai Pro Light",
|
|
"One Dark",
|
|
]
|
|
);
|
|
});
|
|
|
|
fs.insert_tree(
|
|
"/the-extension-dir/installed/zed-gruvbox",
|
|
json!({
|
|
"extension.json": r#"{
|
|
"id": "zed-gruvbox",
|
|
"name": "Zed Gruvbox",
|
|
"version": "1.0.0",
|
|
"themes": {
|
|
"Gruvbox": "themes/gruvbox.json"
|
|
}
|
|
}"#,
|
|
"themes": {
|
|
"gruvbox.json": r#"{
|
|
"name": "Gruvbox",
|
|
"author": "Someone Else",
|
|
"themes": [
|
|
{
|
|
"name": "Gruvbox",
|
|
"appearance": "dark",
|
|
"style": {}
|
|
}
|
|
]
|
|
}"#,
|
|
}
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
expected_index.extensions.insert(
|
|
"zed-gruvbox".into(),
|
|
ExtensionIndexEntry {
|
|
manifest: Arc::new(ExtensionManifest {
|
|
id: "zed-gruvbox".into(),
|
|
name: "Zed Gruvbox".into(),
|
|
version: "1.0.0".into(),
|
|
description: None,
|
|
authors: vec![],
|
|
repository: None,
|
|
themes: vec!["themes/gruvbox.json".into()],
|
|
lib: Default::default(),
|
|
languages: Default::default(),
|
|
grammars: BTreeMap::default(),
|
|
language_servers: BTreeMap::default(),
|
|
}),
|
|
dev: false,
|
|
},
|
|
);
|
|
expected_index.themes.insert(
|
|
"Gruvbox".into(),
|
|
ExtensionIndexThemeEntry {
|
|
extension: "zed-gruvbox".into(),
|
|
path: "themes/gruvbox.json".into(),
|
|
},
|
|
);
|
|
|
|
let _ = store.update(cx, |store, cx| store.reload(None, cx));
|
|
|
|
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
|
|
store.read_with(cx, |store, _| {
|
|
let index = &store.extension_index;
|
|
assert_eq!(index.extensions, expected_index.extensions);
|
|
assert_eq!(index.languages, expected_index.languages);
|
|
assert_eq!(index.themes, expected_index.themes);
|
|
|
|
assert_eq!(
|
|
theme_registry.list_names(false),
|
|
[
|
|
"Gruvbox",
|
|
"Monokai Dark",
|
|
"Monokai Light",
|
|
"Monokai Pro Dark",
|
|
"Monokai Pro Light",
|
|
"One Dark",
|
|
]
|
|
);
|
|
});
|
|
|
|
let prev_fs_metadata_call_count = fs.metadata_call_count();
|
|
let prev_fs_read_dir_call_count = fs.read_dir_call_count();
|
|
|
|
// Create new extension store, as if Zed were restarting.
|
|
drop(store);
|
|
let store = cx.new_model(|cx| {
|
|
ExtensionStore::new(
|
|
PathBuf::from("/the-extension-dir"),
|
|
None,
|
|
fs.clone(),
|
|
http_client.clone(),
|
|
node_runtime.clone(),
|
|
language_registry.clone(),
|
|
theme_registry.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
cx.executor().run_until_parked();
|
|
store.read_with(cx, |store, _| {
|
|
assert_eq!(store.extension_index, expected_index);
|
|
assert_eq!(
|
|
language_registry.language_names(),
|
|
["ERB", "Plain Text", "Ruby"]
|
|
);
|
|
assert_eq!(
|
|
language_registry.grammar_names(),
|
|
["embedded_template".into(), "ruby".into()]
|
|
);
|
|
assert_eq!(
|
|
theme_registry.list_names(false),
|
|
[
|
|
"Gruvbox",
|
|
"Monokai Dark",
|
|
"Monokai Light",
|
|
"Monokai Pro Dark",
|
|
"Monokai Pro Light",
|
|
"One Dark",
|
|
]
|
|
);
|
|
|
|
// The on-disk manifest limits the number of FS calls that need to be made
|
|
// on startup.
|
|
assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
|
|
assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
|
|
});
|
|
|
|
store.update(cx, |store, cx| {
|
|
store.uninstall_extension("zed-ruby".into(), cx)
|
|
});
|
|
|
|
cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
|
|
expected_index.extensions.remove("zed-ruby");
|
|
expected_index.languages.remove("Ruby");
|
|
expected_index.languages.remove("ERB");
|
|
|
|
store.read_with(cx, |store, _| {
|
|
assert_eq!(store.extension_index, expected_index);
|
|
assert_eq!(language_registry.language_names(), ["Plain Text"]);
|
|
assert_eq!(language_registry.grammar_names(), []);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
cx.executor().allow_parking();
|
|
|
|
let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
|
|
.parent()
|
|
.unwrap()
|
|
.parent()
|
|
.unwrap();
|
|
let cache_dir = root_dir.join("target");
|
|
let gleam_extension_dir = root_dir.join("extensions").join("gleam");
|
|
|
|
let fs = Arc::new(RealFs);
|
|
let extensions_dir = temp_tree(json!({
|
|
"installed": {},
|
|
"work": {}
|
|
}));
|
|
let project_dir = temp_tree(json!({
|
|
"test.gleam": ""
|
|
}));
|
|
|
|
let extensions_dir = extensions_dir.path().canonicalize().unwrap();
|
|
let project_dir = project_dir.path().canonicalize().unwrap();
|
|
|
|
let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
|
|
|
|
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
|
|
let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
|
|
let node_runtime = FakeNodeRuntime::new();
|
|
|
|
let mut status_updates = language_registry.language_server_binary_statuses();
|
|
|
|
struct FakeLanguageServerVersion {
|
|
version: String,
|
|
binary_contents: String,
|
|
http_request_count: usize,
|
|
}
|
|
|
|
let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
|
|
version: "v1.2.3".into(),
|
|
binary_contents: "the-binary-contents".into(),
|
|
http_request_count: 0,
|
|
}));
|
|
|
|
let http_client = FakeHttpClient::create({
|
|
let language_server_version = language_server_version.clone();
|
|
move |request| {
|
|
let language_server_version = language_server_version.clone();
|
|
async move {
|
|
language_server_version.lock().http_request_count += 1;
|
|
let version = language_server_version.lock().version.clone();
|
|
let binary_contents = language_server_version.lock().binary_contents.clone();
|
|
|
|
let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
|
|
let asset_download_uri =
|
|
format!("https://fake-download.example.com/gleam-{version}");
|
|
|
|
let uri = request.uri().to_string();
|
|
if uri == github_releases_uri {
|
|
Ok(Response::new(
|
|
json!([
|
|
{
|
|
"tag_name": version,
|
|
"prerelease": false,
|
|
"tarball_url": "",
|
|
"zipball_url": "",
|
|
"assets": [
|
|
{
|
|
"name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
|
|
"browser_download_url": asset_download_uri
|
|
}
|
|
]
|
|
}
|
|
])
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
} else if uri == asset_download_uri {
|
|
let mut bytes = Vec::<u8>::new();
|
|
let mut archive = async_tar::Builder::new(&mut bytes);
|
|
let mut header = async_tar::Header::new_gnu();
|
|
header.set_size(binary_contents.len() as u64);
|
|
archive
|
|
.append_data(&mut header, "gleam", binary_contents.as_bytes())
|
|
.await
|
|
.unwrap();
|
|
archive.into_inner().await.unwrap();
|
|
let mut gzipped_bytes = Vec::new();
|
|
let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
|
|
encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
|
|
Ok(Response::new(gzipped_bytes.into()))
|
|
} else {
|
|
Ok(Response::builder().status(404).body("not found".into())?)
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
let extension_store = cx.new_model(|cx| {
|
|
ExtensionStore::new(
|
|
extensions_dir.clone(),
|
|
Some(cache_dir),
|
|
fs.clone(),
|
|
http_client.clone(),
|
|
node_runtime,
|
|
language_registry.clone(),
|
|
theme_registry.clone(),
|
|
cx,
|
|
)
|
|
});
|
|
|
|
// Ensure that debounces fire.
|
|
let mut events = cx.events(&extension_store);
|
|
let executor = cx.executor();
|
|
let _task = cx.executor().spawn(async move {
|
|
while let Some(event) = events.next().await {
|
|
match event {
|
|
crate::Event::StartedReloading => {
|
|
executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
|
|
}
|
|
_ => (),
|
|
}
|
|
}
|
|
});
|
|
|
|
extension_store
|
|
.update(cx, |store, cx| {
|
|
store.install_dev_extension(gleam_extension_dir.clone(), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let mut fake_servers = language_registry.fake_language_servers("Gleam");
|
|
|
|
let buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_local_buffer(project_dir.join("test.gleam"), cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
let fake_server = fake_servers.next().await.unwrap();
|
|
let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
|
|
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
|
|
|
|
assert_eq!(fake_server.binary.path, expected_server_path);
|
|
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
|
|
assert_eq!(
|
|
fs.load(&expected_server_path).await.unwrap(),
|
|
expected_binary_contents
|
|
);
|
|
assert_eq!(language_server_version.lock().http_request_count, 2);
|
|
assert_eq!(
|
|
[
|
|
status_updates.next().await.unwrap(),
|
|
status_updates.next().await.unwrap(),
|
|
status_updates.next().await.unwrap(),
|
|
],
|
|
[
|
|
(
|
|
LanguageServerName("gleam".into()),
|
|
LanguageServerBinaryStatus::CheckingForUpdate
|
|
),
|
|
(
|
|
LanguageServerName("gleam".into()),
|
|
LanguageServerBinaryStatus::Downloading
|
|
),
|
|
(
|
|
LanguageServerName("gleam".into()),
|
|
LanguageServerBinaryStatus::Downloaded
|
|
)
|
|
]
|
|
);
|
|
|
|
// Simulate a new version of the language server being released
|
|
language_server_version.lock().version = "v2.0.0".into();
|
|
language_server_version.lock().binary_contents = "the-new-binary-contents".into();
|
|
language_server_version.lock().http_request_count = 0;
|
|
|
|
// Start a new instance of the language server.
|
|
project.update(cx, |project, cx| {
|
|
project.restart_language_servers_for_buffers([buffer.clone()], cx)
|
|
});
|
|
|
|
// The extension has cached the binary path, and does not attempt
|
|
// to reinstall it.
|
|
let fake_server = fake_servers.next().await.unwrap();
|
|
assert_eq!(fake_server.binary.path, expected_server_path);
|
|
assert_eq!(
|
|
fs.load(&expected_server_path).await.unwrap(),
|
|
expected_binary_contents
|
|
);
|
|
assert_eq!(language_server_version.lock().http_request_count, 0);
|
|
|
|
// Reload the extension, clearing its cache.
|
|
// Start a new instance of the language server.
|
|
extension_store
|
|
.update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
|
|
.await;
|
|
|
|
cx.executor().run_until_parked();
|
|
project.update(cx, |project, cx| {
|
|
project.restart_language_servers_for_buffers([buffer.clone()], cx)
|
|
});
|
|
|
|
// The extension re-fetches the latest version of the language server.
|
|
let fake_server = fake_servers.next().await.unwrap();
|
|
let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
|
|
let expected_binary_contents = language_server_version.lock().binary_contents.clone();
|
|
assert_eq!(fake_server.binary.path, new_expected_server_path);
|
|
assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
|
|
assert_eq!(
|
|
fs.load(&new_expected_server_path).await.unwrap(),
|
|
expected_binary_contents
|
|
);
|
|
|
|
// The old language server directory has been cleaned up.
|
|
assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
|
|
}
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let store = SettingsStore::test(cx);
|
|
cx.set_global(store);
|
|
theme::init(theme::LoadThemes::JustBase, cx);
|
|
Project::init_settings(cx);
|
|
language::init(cx);
|
|
});
|
|
}
|