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