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