1use crate::{
2 Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
3 ExtensionIndexThemeEntry, ExtensionManifest, ExtensionSettings, ExtensionStore,
4 GrammarManifestEntry, SchemaVersion, RELOAD_DEBOUNCE_DURATION,
5};
6use async_compression::futures::bufread::GzipEncoder;
7use collections::BTreeMap;
8use extension::ExtensionHostProxy;
9use fs::{FakeFs, Fs, RealFs};
10use futures::{io::BufReader, AsyncReadExt, StreamExt};
11use gpui::{Context, SemanticVersion, TestAppContext};
12use http_client::{FakeHttpClient, Response};
13use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus};
14use lsp::LanguageServerName;
15use node_runtime::NodeRuntime;
16use parking_lot::Mutex;
17use project::{Project, DEFAULT_COMPLETION_CONTEXT};
18use release_channel::AppVersion;
19use reqwest_client::ReqwestClient;
20use serde_json::json;
21use settings::{Settings as _, SettingsStore};
22use std::{
23 ffi::OsString,
24 path::{Path, PathBuf},
25 sync::Arc,
26};
27use theme::ThemeRegistry;
28use util::test::temp_tree;
29
30#[cfg(test)]
31#[ctor::ctor]
32fn init_logger() {
33 if std::env::var("RUST_LOG").is_ok() {
34 env_logger::init();
35 }
36}
37
38#[gpui::test]
39async fn test_extension_store(cx: &mut TestAppContext) {
40 init_test(cx);
41
42 let fs = FakeFs::new(cx.executor());
43 let http_client = FakeHttpClient::with_200_response();
44
45 fs.insert_tree(
46 "/the-extension-dir",
47 json!({
48 "installed": {
49 "zed-monokai": {
50 "extension.json": r#"{
51 "id": "zed-monokai",
52 "name": "Zed Monokai",
53 "version": "2.0.0",
54 "themes": {
55 "Monokai Dark": "themes/monokai.json",
56 "Monokai Light": "themes/monokai.json",
57 "Monokai Pro Dark": "themes/monokai-pro.json",
58 "Monokai Pro Light": "themes/monokai-pro.json"
59 }
60 }"#,
61 "themes": {
62 "monokai.json": r#"{
63 "name": "Monokai",
64 "author": "Someone",
65 "themes": [
66 {
67 "name": "Monokai Dark",
68 "appearance": "dark",
69 "style": {}
70 },
71 {
72 "name": "Monokai Light",
73 "appearance": "light",
74 "style": {}
75 }
76 ]
77 }"#,
78 "monokai-pro.json": r#"{
79 "name": "Monokai Pro",
80 "author": "Someone",
81 "themes": [
82 {
83 "name": "Monokai Pro Dark",
84 "appearance": "dark",
85 "style": {}
86 },
87 {
88 "name": "Monokai Pro Light",
89 "appearance": "light",
90 "style": {}
91 }
92 ]
93 }"#,
94 }
95 },
96 "zed-ruby": {
97 "extension.json": r#"{
98 "id": "zed-ruby",
99 "name": "Zed Ruby",
100 "version": "1.0.0",
101 "grammars": {
102 "ruby": "grammars/ruby.wasm",
103 "embedded_template": "grammars/embedded_template.wasm"
104 },
105 "languages": {
106 "ruby": "languages/ruby",
107 "erb": "languages/erb"
108 }
109 }"#,
110 "grammars": {
111 "ruby.wasm": "",
112 "embedded_template.wasm": "",
113 },
114 "languages": {
115 "ruby": {
116 "config.toml": r#"
117 name = "Ruby"
118 grammar = "ruby"
119 path_suffixes = ["rb"]
120 "#,
121 "highlights.scm": "",
122 },
123 "erb": {
124 "config.toml": r#"
125 name = "ERB"
126 grammar = "embedded_template"
127 path_suffixes = ["erb"]
128 "#,
129 "highlights.scm": "",
130 }
131 },
132 }
133 }
134 }),
135 )
136 .await;
137
138 let mut expected_index = ExtensionIndex {
139 extensions: [
140 (
141 "zed-ruby".into(),
142 ExtensionIndexEntry {
143 manifest: Arc::new(ExtensionManifest {
144 id: "zed-ruby".into(),
145 name: "Zed Ruby".into(),
146 version: "1.0.0".into(),
147 schema_version: SchemaVersion::ZERO,
148 description: None,
149 authors: Vec::new(),
150 repository: None,
151 themes: Default::default(),
152 lib: Default::default(),
153 languages: vec!["languages/erb".into(), "languages/ruby".into()],
154 grammars: [
155 ("embedded_template".into(), GrammarManifestEntry::default()),
156 ("ruby".into(), GrammarManifestEntry::default()),
157 ]
158 .into_iter()
159 .collect(),
160 language_servers: BTreeMap::default(),
161 context_servers: BTreeMap::default(),
162 slash_commands: BTreeMap::default(),
163 indexed_docs_providers: BTreeMap::default(),
164 snippets: None,
165 }),
166 dev: false,
167 },
168 ),
169 (
170 "zed-monokai".into(),
171 ExtensionIndexEntry {
172 manifest: Arc::new(ExtensionManifest {
173 id: "zed-monokai".into(),
174 name: "Zed Monokai".into(),
175 version: "2.0.0".into(),
176 schema_version: SchemaVersion::ZERO,
177 description: None,
178 authors: vec![],
179 repository: None,
180 themes: vec![
181 "themes/monokai-pro.json".into(),
182 "themes/monokai.json".into(),
183 ],
184 lib: Default::default(),
185 languages: Default::default(),
186 grammars: BTreeMap::default(),
187 language_servers: BTreeMap::default(),
188 context_servers: BTreeMap::default(),
189 slash_commands: BTreeMap::default(),
190 indexed_docs_providers: BTreeMap::default(),
191 snippets: None,
192 }),
193 dev: false,
194 },
195 ),
196 ]
197 .into_iter()
198 .collect(),
199 languages: [
200 (
201 "ERB".into(),
202 ExtensionIndexLanguageEntry {
203 extension: "zed-ruby".into(),
204 path: "languages/erb".into(),
205 grammar: Some("embedded_template".into()),
206 hidden: false,
207 matcher: LanguageMatcher {
208 path_suffixes: vec!["erb".into()],
209 first_line_pattern: None,
210 },
211 },
212 ),
213 (
214 "Ruby".into(),
215 ExtensionIndexLanguageEntry {
216 extension: "zed-ruby".into(),
217 path: "languages/ruby".into(),
218 grammar: Some("ruby".into()),
219 hidden: false,
220 matcher: LanguageMatcher {
221 path_suffixes: vec!["rb".into()],
222 first_line_pattern: None,
223 },
224 },
225 ),
226 ]
227 .into_iter()
228 .collect(),
229 themes: [
230 (
231 "Monokai Dark".into(),
232 ExtensionIndexThemeEntry {
233 extension: "zed-monokai".into(),
234 path: "themes/monokai.json".into(),
235 },
236 ),
237 (
238 "Monokai Light".into(),
239 ExtensionIndexThemeEntry {
240 extension: "zed-monokai".into(),
241 path: "themes/monokai.json".into(),
242 },
243 ),
244 (
245 "Monokai Pro Dark".into(),
246 ExtensionIndexThemeEntry {
247 extension: "zed-monokai".into(),
248 path: "themes/monokai-pro.json".into(),
249 },
250 ),
251 (
252 "Monokai Pro Light".into(),
253 ExtensionIndexThemeEntry {
254 extension: "zed-monokai".into(),
255 path: "themes/monokai-pro.json".into(),
256 },
257 ),
258 ]
259 .into_iter()
260 .collect(),
261 };
262
263 let proxy = Arc::new(ExtensionHostProxy::new());
264 let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
265 theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor());
266 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
267 language_extension::init(proxy.clone(), language_registry.clone());
268 let node_runtime = NodeRuntime::unavailable();
269
270 let store = cx.new_model(|cx| {
271 ExtensionStore::new(
272 PathBuf::from("/the-extension-dir"),
273 None,
274 proxy.clone(),
275 fs.clone(),
276 http_client.clone(),
277 http_client.clone(),
278 None,
279 node_runtime.clone(),
280 cx,
281 )
282 });
283
284 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
285 store.read_with(cx, |store, _| {
286 let index = &store.extension_index;
287 assert_eq!(index.extensions, expected_index.extensions);
288 assert_eq!(index.languages, expected_index.languages);
289 assert_eq!(index.themes, expected_index.themes);
290
291 assert_eq!(
292 language_registry.language_names(),
293 ["ERB", "Plain Text", "Ruby"]
294 );
295 assert_eq!(
296 theme_registry.list_names(),
297 [
298 "Monokai Dark",
299 "Monokai Light",
300 "Monokai Pro Dark",
301 "Monokai Pro Light",
302 "One Dark",
303 ]
304 );
305 });
306
307 fs.insert_tree(
308 "/the-extension-dir/installed/zed-gruvbox",
309 json!({
310 "extension.json": r#"{
311 "id": "zed-gruvbox",
312 "name": "Zed Gruvbox",
313 "version": "1.0.0",
314 "themes": {
315 "Gruvbox": "themes/gruvbox.json"
316 }
317 }"#,
318 "themes": {
319 "gruvbox.json": r#"{
320 "name": "Gruvbox",
321 "author": "Someone Else",
322 "themes": [
323 {
324 "name": "Gruvbox",
325 "appearance": "dark",
326 "style": {}
327 }
328 ]
329 }"#,
330 }
331 }),
332 )
333 .await;
334
335 expected_index.extensions.insert(
336 "zed-gruvbox".into(),
337 ExtensionIndexEntry {
338 manifest: Arc::new(ExtensionManifest {
339 id: "zed-gruvbox".into(),
340 name: "Zed Gruvbox".into(),
341 version: "1.0.0".into(),
342 schema_version: SchemaVersion::ZERO,
343 description: None,
344 authors: vec![],
345 repository: None,
346 themes: vec!["themes/gruvbox.json".into()],
347 lib: Default::default(),
348 languages: Default::default(),
349 grammars: BTreeMap::default(),
350 language_servers: BTreeMap::default(),
351 context_servers: BTreeMap::default(),
352 slash_commands: BTreeMap::default(),
353 indexed_docs_providers: BTreeMap::default(),
354 snippets: None,
355 }),
356 dev: false,
357 },
358 );
359 expected_index.themes.insert(
360 "Gruvbox".into(),
361 ExtensionIndexThemeEntry {
362 extension: "zed-gruvbox".into(),
363 path: "themes/gruvbox.json".into(),
364 },
365 );
366
367 #[allow(clippy::let_underscore_future)]
368 let _ = store.update(cx, |store, cx| store.reload(None, cx));
369
370 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
371 store.read_with(cx, |store, _| {
372 let index = &store.extension_index;
373 assert_eq!(index.extensions, expected_index.extensions);
374 assert_eq!(index.languages, expected_index.languages);
375 assert_eq!(index.themes, expected_index.themes);
376
377 assert_eq!(
378 theme_registry.list_names(),
379 [
380 "Gruvbox",
381 "Monokai Dark",
382 "Monokai Light",
383 "Monokai Pro Dark",
384 "Monokai Pro Light",
385 "One Dark",
386 ]
387 );
388 });
389
390 let prev_fs_metadata_call_count = fs.metadata_call_count();
391 let prev_fs_read_dir_call_count = fs.read_dir_call_count();
392
393 // Create new extension store, as if Zed were restarting.
394 drop(store);
395 let store = cx.new_model(|cx| {
396 ExtensionStore::new(
397 PathBuf::from("/the-extension-dir"),
398 None,
399 proxy,
400 fs.clone(),
401 http_client.clone(),
402 http_client.clone(),
403 None,
404 node_runtime.clone(),
405 cx,
406 )
407 });
408
409 cx.executor().run_until_parked();
410 store.read_with(cx, |store, _| {
411 assert_eq!(store.extension_index, expected_index);
412 assert_eq!(
413 language_registry.language_names(),
414 ["ERB", "Plain Text", "Ruby"]
415 );
416 assert_eq!(
417 language_registry.grammar_names(),
418 ["embedded_template".into(), "ruby".into()]
419 );
420 assert_eq!(
421 theme_registry.list_names(),
422 [
423 "Gruvbox",
424 "Monokai Dark",
425 "Monokai Light",
426 "Monokai Pro Dark",
427 "Monokai Pro Light",
428 "One Dark",
429 ]
430 );
431
432 // The on-disk manifest limits the number of FS calls that need to be made
433 // on startup.
434 assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
435 assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
436 });
437
438 store.update(cx, |store, cx| {
439 store.uninstall_extension("zed-ruby".into(), cx)
440 });
441
442 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
443 expected_index.extensions.remove("zed-ruby");
444 expected_index.languages.remove("Ruby");
445 expected_index.languages.remove("ERB");
446
447 store.read_with(cx, |store, _| {
448 assert_eq!(store.extension_index, expected_index);
449 assert_eq!(language_registry.language_names(), ["Plain Text"]);
450 assert_eq!(language_registry.grammar_names(), []);
451 });
452}
453
454#[gpui::test]
455async fn test_extension_store_with_test_extension(cx: &mut TestAppContext) {
456 init_test(cx);
457 cx.executor().allow_parking();
458
459 let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
460 .parent()
461 .unwrap()
462 .parent()
463 .unwrap();
464 let cache_dir = root_dir.join("target");
465 let test_extension_id = "test-extension";
466 let test_extension_dir = root_dir.join("extensions").join(test_extension_id);
467
468 let fs = Arc::new(RealFs::default());
469 let extensions_dir = temp_tree(json!({
470 "installed": {},
471 "work": {}
472 }));
473 let project_dir = temp_tree(json!({
474 "test.gleam": ""
475 }));
476
477 let extensions_dir = extensions_dir.path().canonicalize().unwrap();
478 let project_dir = project_dir.path().canonicalize().unwrap();
479
480 let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
481
482 let proxy = Arc::new(ExtensionHostProxy::new());
483 let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
484 theme_extension::init(proxy.clone(), theme_registry.clone(), cx.executor());
485 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
486 language_extension::init(proxy.clone(), language_registry.clone());
487 let node_runtime = NodeRuntime::unavailable();
488
489 let mut status_updates = language_registry.language_server_binary_statuses();
490
491 struct FakeLanguageServerVersion {
492 version: String,
493 binary_contents: String,
494 http_request_count: usize,
495 }
496
497 let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
498 version: "v1.2.3".into(),
499 binary_contents: "the-binary-contents".into(),
500 http_request_count: 0,
501 }));
502
503 let extension_client = FakeHttpClient::create({
504 let language_server_version = language_server_version.clone();
505 move |request| {
506 let language_server_version = language_server_version.clone();
507 async move {
508 let version = language_server_version.lock().version.clone();
509 let binary_contents = language_server_version.lock().binary_contents.clone();
510
511 let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
512 let asset_download_uri =
513 format!("https://fake-download.example.com/gleam-{version}");
514
515 let uri = request.uri().to_string();
516 if uri == github_releases_uri {
517 language_server_version.lock().http_request_count += 1;
518 Ok(Response::new(
519 json!([
520 {
521 "tag_name": version,
522 "prerelease": false,
523 "tarball_url": "",
524 "zipball_url": "",
525 "assets": [
526 {
527 "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
528 "browser_download_url": asset_download_uri
529 },
530 {
531 "name": format!("gleam-{version}-x86_64-unknown-linux-musl.tar.gz"),
532 "browser_download_url": asset_download_uri
533 },
534 {
535 "name": format!("gleam-{version}-aarch64-unknown-linux-musl.tar.gz"),
536 "browser_download_url": asset_download_uri
537 }
538 ]
539 }
540 ])
541 .to_string()
542 .into(),
543 ))
544 } else if uri == asset_download_uri {
545 language_server_version.lock().http_request_count += 1;
546 let mut bytes = Vec::<u8>::new();
547 let mut archive = async_tar::Builder::new(&mut bytes);
548 let mut header = async_tar::Header::new_gnu();
549 header.set_size(binary_contents.len() as u64);
550 archive
551 .append_data(&mut header, "gleam", binary_contents.as_bytes())
552 .await
553 .unwrap();
554 archive.into_inner().await.unwrap();
555 let mut gzipped_bytes = Vec::new();
556 let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
557 encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
558 Ok(Response::new(gzipped_bytes.into()))
559 } else {
560 Ok(Response::builder().status(404).body("not found".into())?)
561 }
562 }
563 }
564 });
565 let user_agent = cx.update(|cx| {
566 format!(
567 "Zed/{} ({}; {})",
568 AppVersion::global(cx),
569 std::env::consts::OS,
570 std::env::consts::ARCH
571 )
572 });
573 let builder_client =
574 Arc::new(ReqwestClient::user_agent(&user_agent).expect("Could not create HTTP client"));
575
576 let extension_store = cx.new_model(|cx| {
577 ExtensionStore::new(
578 extensions_dir.clone(),
579 Some(cache_dir),
580 proxy,
581 fs.clone(),
582 extension_client.clone(),
583 builder_client,
584 None,
585 node_runtime,
586 cx,
587 )
588 });
589
590 // Ensure that debounces fire.
591 let mut events = cx.events(&extension_store);
592 let executor = cx.executor();
593 let _task = cx.executor().spawn(async move {
594 while let Some(event) = events.next().await {
595 if let Event::StartedReloading = event {
596 executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
597 }
598 }
599 });
600
601 extension_store.update(cx, |_, cx| {
602 cx.subscribe(&extension_store, |_, _, event, _| {
603 if matches!(event, Event::ExtensionFailedToLoad(_)) {
604 panic!("extension failed to load");
605 }
606 })
607 .detach();
608 });
609
610 extension_store
611 .update(cx, |store, cx| {
612 store.install_dev_extension(test_extension_dir.clone(), cx)
613 })
614 .await
615 .unwrap();
616
617 let mut fake_servers = language_registry.register_fake_language_server(
618 LanguageServerName("gleam".into()),
619 lsp::ServerCapabilities {
620 completion_provider: Some(Default::default()),
621 ..Default::default()
622 },
623 None,
624 );
625
626 let buffer = project
627 .update(cx, |project, cx| {
628 project.open_local_buffer(project_dir.join("test.gleam"), cx)
629 })
630 .await
631 .unwrap();
632
633 let fake_server = fake_servers.next().await.unwrap();
634 let expected_server_path =
635 extensions_dir.join(format!("work/{test_extension_id}/gleam-v1.2.3/gleam"));
636 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
637
638 assert_eq!(fake_server.binary.path, expected_server_path);
639 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
640 assert_eq!(
641 fs.load(&expected_server_path).await.unwrap(),
642 expected_binary_contents
643 );
644 assert_eq!(language_server_version.lock().http_request_count, 2);
645 assert_eq!(
646 [
647 status_updates.next().await.unwrap(),
648 status_updates.next().await.unwrap(),
649 status_updates.next().await.unwrap(),
650 ],
651 [
652 (
653 LanguageServerName("gleam".into()),
654 LanguageServerBinaryStatus::CheckingForUpdate
655 ),
656 (
657 LanguageServerName("gleam".into()),
658 LanguageServerBinaryStatus::Downloading
659 ),
660 (
661 LanguageServerName("gleam".into()),
662 LanguageServerBinaryStatus::None
663 )
664 ]
665 );
666
667 // The extension creates custom labels for completion items.
668 fake_server.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
669 Ok(Some(lsp::CompletionResponse::Array(vec![
670 lsp::CompletionItem {
671 label: "foo".into(),
672 kind: Some(lsp::CompletionItemKind::FUNCTION),
673 detail: Some("fn() -> Result(Nil, Error)".into()),
674 ..Default::default()
675 },
676 lsp::CompletionItem {
677 label: "bar.baz".into(),
678 kind: Some(lsp::CompletionItemKind::FUNCTION),
679 detail: Some("fn(List(a)) -> a".into()),
680 ..Default::default()
681 },
682 lsp::CompletionItem {
683 label: "Quux".into(),
684 kind: Some(lsp::CompletionItemKind::CONSTRUCTOR),
685 detail: Some("fn(String) -> T".into()),
686 ..Default::default()
687 },
688 lsp::CompletionItem {
689 label: "my_string".into(),
690 kind: Some(lsp::CompletionItemKind::CONSTANT),
691 detail: Some("String".into()),
692 ..Default::default()
693 },
694 ])))
695 });
696
697 let completion_labels = project
698 .update(cx, |project, cx| {
699 project.completions(&buffer, 0, DEFAULT_COMPLETION_CONTEXT, cx)
700 })
701 .await
702 .unwrap()
703 .into_iter()
704 .map(|c| c.label.text)
705 .collect::<Vec<_>>();
706 assert_eq!(
707 completion_labels,
708 [
709 "foo: fn() -> Result(Nil, Error)".to_string(),
710 "bar.baz: fn(List(a)) -> a".to_string(),
711 "Quux: fn(String) -> T".to_string(),
712 "my_string: String".to_string(),
713 ]
714 );
715
716 // Simulate a new version of the language server being released
717 language_server_version.lock().version = "v2.0.0".into();
718 language_server_version.lock().binary_contents = "the-new-binary-contents".into();
719 language_server_version.lock().http_request_count = 0;
720
721 // Start a new instance of the language server.
722 project.update(cx, |project, cx| {
723 project.restart_language_servers_for_buffers([buffer.clone()], cx)
724 });
725
726 // The extension has cached the binary path, and does not attempt
727 // to reinstall it.
728 let fake_server = fake_servers.next().await.unwrap();
729 assert_eq!(fake_server.binary.path, expected_server_path);
730 assert_eq!(
731 fs.load(&expected_server_path).await.unwrap(),
732 expected_binary_contents
733 );
734 assert_eq!(language_server_version.lock().http_request_count, 0);
735
736 // Reload the extension, clearing its cache.
737 // Start a new instance of the language server.
738 extension_store
739 .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
740 .await;
741
742 cx.executor().run_until_parked();
743 project.update(cx, |project, cx| {
744 project.restart_language_servers_for_buffers([buffer.clone()], cx)
745 });
746
747 // The extension re-fetches the latest version of the language server.
748 let fake_server = fake_servers.next().await.unwrap();
749 let new_expected_server_path =
750 extensions_dir.join(format!("work/{test_extension_id}/gleam-v2.0.0/gleam"));
751 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
752 assert_eq!(fake_server.binary.path, new_expected_server_path);
753 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
754 assert_eq!(
755 fs.load(&new_expected_server_path).await.unwrap(),
756 expected_binary_contents
757 );
758
759 // The old language server directory has been cleaned up.
760 assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
761}
762
763fn init_test(cx: &mut TestAppContext) {
764 cx.update(|cx| {
765 let store = SettingsStore::test(cx);
766 cx.set_global(store);
767 release_channel::init(SemanticVersion::default(), cx);
768 theme::init(theme::LoadThemes::JustBase, cx);
769 Project::init_settings(cx);
770 ExtensionSettings::register(cx);
771 language::init(cx);
772 });
773}