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