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