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