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