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