1use crate::{
2 Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
3 ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
4 RELOAD_DEBOUNCE_DURATION,
5};
6use async_compression::futures::bufread::GzipEncoder;
7use collections::BTreeMap;
8use fs::{FakeFs, Fs, RealFs};
9use futures::{io::BufReader, AsyncReadExt, StreamExt};
10use gpui::{Context, TestAppContext};
11use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
12use node_runtime::FakeNodeRuntime;
13use parking_lot::Mutex;
14use project::Project;
15use serde_json::json;
16use settings::SettingsStore;
17use std::{
18 ffi::OsString,
19 path::{Path, PathBuf},
20 sync::Arc,
21};
22use theme::ThemeRegistry;
23use util::{
24 http::{FakeHttpClient, Response},
25 test::temp_tree,
26};
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 cx.update(|cx| {
39 let store = SettingsStore::test(cx);
40 cx.set_global(store);
41 theme::init(theme::LoadThemes::JustBase, cx);
42 });
43
44 let fs = FakeFs::new(cx.executor());
45 let http_client = FakeHttpClient::with_200_response();
46
47 fs.insert_tree(
48 "/the-extension-dir",
49 json!({
50 "installed": {
51 "zed-monokai": {
52 "extension.json": r#"{
53 "id": "zed-monokai",
54 "name": "Zed Monokai",
55 "version": "2.0.0",
56 "themes": {
57 "Monokai Dark": "themes/monokai.json",
58 "Monokai Light": "themes/monokai.json",
59 "Monokai Pro Dark": "themes/monokai-pro.json",
60 "Monokai Pro Light": "themes/monokai-pro.json"
61 }
62 }"#,
63 "themes": {
64 "monokai.json": r#"{
65 "name": "Monokai",
66 "author": "Someone",
67 "themes": [
68 {
69 "name": "Monokai Dark",
70 "appearance": "dark",
71 "style": {}
72 },
73 {
74 "name": "Monokai Light",
75 "appearance": "light",
76 "style": {}
77 }
78 ]
79 }"#,
80 "monokai-pro.json": r#"{
81 "name": "Monokai Pro",
82 "author": "Someone",
83 "themes": [
84 {
85 "name": "Monokai Pro Dark",
86 "appearance": "dark",
87 "style": {}
88 },
89 {
90 "name": "Monokai Pro Light",
91 "appearance": "light",
92 "style": {}
93 }
94 ]
95 }"#,
96 }
97 },
98 "zed-ruby": {
99 "extension.json": r#"{
100 "id": "zed-ruby",
101 "name": "Zed Ruby",
102 "version": "1.0.0",
103 "grammars": {
104 "ruby": "grammars/ruby.wasm",
105 "embedded_template": "grammars/embedded_template.wasm"
106 },
107 "languages": {
108 "ruby": "languages/ruby",
109 "erb": "languages/erb"
110 }
111 }"#,
112 "grammars": {
113 "ruby.wasm": "",
114 "embedded_template.wasm": "",
115 },
116 "languages": {
117 "ruby": {
118 "config.toml": r#"
119 name = "Ruby"
120 grammar = "ruby"
121 path_suffixes = ["rb"]
122 "#,
123 "highlights.scm": "",
124 },
125 "erb": {
126 "config.toml": r#"
127 name = "ERB"
128 grammar = "embedded_template"
129 path_suffixes = ["erb"]
130 "#,
131 "highlights.scm": "",
132 }
133 },
134 }
135 }
136 }),
137 )
138 .await;
139
140 let mut expected_index = ExtensionIndex {
141 extensions: [
142 (
143 "zed-ruby".into(),
144 ExtensionIndexEntry {
145 manifest: Arc::new(ExtensionManifest {
146 id: "zed-ruby".into(),
147 name: "Zed Ruby".into(),
148 version: "1.0.0".into(),
149 schema_version: 0,
150 description: None,
151 authors: Vec::new(),
152 repository: None,
153 themes: Default::default(),
154 lib: Default::default(),
155 languages: vec!["languages/erb".into(), "languages/ruby".into()],
156 grammars: [
157 ("embedded_template".into(), GrammarManifestEntry::default()),
158 ("ruby".into(), GrammarManifestEntry::default()),
159 ]
160 .into_iter()
161 .collect(),
162 language_servers: 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: 0,
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 }),
187 dev: false,
188 },
189 ),
190 ]
191 .into_iter()
192 .collect(),
193 languages: [
194 (
195 "ERB".into(),
196 ExtensionIndexLanguageEntry {
197 extension: "zed-ruby".into(),
198 path: "languages/erb".into(),
199 grammar: Some("embedded_template".into()),
200 matcher: LanguageMatcher {
201 path_suffixes: vec!["erb".into()],
202 first_line_pattern: None,
203 },
204 },
205 ),
206 (
207 "Ruby".into(),
208 ExtensionIndexLanguageEntry {
209 extension: "zed-ruby".into(),
210 path: "languages/ruby".into(),
211 grammar: Some("ruby".into()),
212 matcher: LanguageMatcher {
213 path_suffixes: vec!["rb".into()],
214 first_line_pattern: None,
215 },
216 },
217 ),
218 ]
219 .into_iter()
220 .collect(),
221 themes: [
222 (
223 "Monokai Dark".into(),
224 ExtensionIndexThemeEntry {
225 extension: "zed-monokai".into(),
226 path: "themes/monokai.json".into(),
227 },
228 ),
229 (
230 "Monokai Light".into(),
231 ExtensionIndexThemeEntry {
232 extension: "zed-monokai".into(),
233 path: "themes/monokai.json".into(),
234 },
235 ),
236 (
237 "Monokai Pro Dark".into(),
238 ExtensionIndexThemeEntry {
239 extension: "zed-monokai".into(),
240 path: "themes/monokai-pro.json".into(),
241 },
242 ),
243 (
244 "Monokai Pro Light".into(),
245 ExtensionIndexThemeEntry {
246 extension: "zed-monokai".into(),
247 path: "themes/monokai-pro.json".into(),
248 },
249 ),
250 ]
251 .into_iter()
252 .collect(),
253 };
254
255 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
256 let theme_registry = Arc::new(ThemeRegistry::new(Box::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 node_runtime.clone(),
266 language_registry.clone(),
267 theme_registry.clone(),
268 cx,
269 )
270 });
271
272 cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
273 store.read_with(cx, |store, _| {
274 let index = &store.extension_index;
275 assert_eq!(index.extensions, expected_index.extensions);
276 assert_eq!(index.languages, expected_index.languages);
277 assert_eq!(index.themes, expected_index.themes);
278
279 assert_eq!(
280 language_registry.language_names(),
281 ["ERB", "Plain Text", "Ruby"]
282 );
283 assert_eq!(
284 theme_registry.list_names(false),
285 [
286 "Monokai Dark",
287 "Monokai Light",
288 "Monokai Pro Dark",
289 "Monokai Pro Light",
290 "One Dark",
291 ]
292 );
293 });
294
295 fs.insert_tree(
296 "/the-extension-dir/installed/zed-gruvbox",
297 json!({
298 "extension.json": r#"{
299 "id": "zed-gruvbox",
300 "name": "Zed Gruvbox",
301 "version": "1.0.0",
302 "themes": {
303 "Gruvbox": "themes/gruvbox.json"
304 }
305 }"#,
306 "themes": {
307 "gruvbox.json": r#"{
308 "name": "Gruvbox",
309 "author": "Someone Else",
310 "themes": [
311 {
312 "name": "Gruvbox",
313 "appearance": "dark",
314 "style": {}
315 }
316 ]
317 }"#,
318 }
319 }),
320 )
321 .await;
322
323 expected_index.extensions.insert(
324 "zed-gruvbox".into(),
325 ExtensionIndexEntry {
326 manifest: Arc::new(ExtensionManifest {
327 id: "zed-gruvbox".into(),
328 name: "Zed Gruvbox".into(),
329 version: "1.0.0".into(),
330 schema_version: 0,
331 description: None,
332 authors: vec![],
333 repository: None,
334 themes: vec!["themes/gruvbox.json".into()],
335 lib: Default::default(),
336 languages: Default::default(),
337 grammars: BTreeMap::default(),
338 language_servers: BTreeMap::default(),
339 }),
340 dev: false,
341 },
342 );
343 expected_index.themes.insert(
344 "Gruvbox".into(),
345 ExtensionIndexThemeEntry {
346 extension: "zed-gruvbox".into(),
347 path: "themes/gruvbox.json".into(),
348 },
349 );
350
351 let _ = store.update(cx, |store, cx| store.reload(None, cx));
352
353 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
354 store.read_with(cx, |store, _| {
355 let index = &store.extension_index;
356 assert_eq!(index.extensions, expected_index.extensions);
357 assert_eq!(index.languages, expected_index.languages);
358 assert_eq!(index.themes, expected_index.themes);
359
360 assert_eq!(
361 theme_registry.list_names(false),
362 [
363 "Gruvbox",
364 "Monokai Dark",
365 "Monokai Light",
366 "Monokai Pro Dark",
367 "Monokai Pro Light",
368 "One Dark",
369 ]
370 );
371 });
372
373 let prev_fs_metadata_call_count = fs.metadata_call_count();
374 let prev_fs_read_dir_call_count = fs.read_dir_call_count();
375
376 // Create new extension store, as if Zed were restarting.
377 drop(store);
378 let store = cx.new_model(|cx| {
379 ExtensionStore::new(
380 PathBuf::from("/the-extension-dir"),
381 None,
382 fs.clone(),
383 http_client.clone(),
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);
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 language_server_version.lock().http_request_count += 1;
487 let version = language_server_version.lock().version.clone();
488 let binary_contents = language_server_version.lock().binary_contents.clone();
489
490 let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
491 let asset_download_uri =
492 format!("https://fake-download.example.com/gleam-{version}");
493
494 let uri = request.uri().to_string();
495 if uri == github_releases_uri {
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 let mut bytes = Vec::<u8>::new();
516 let mut archive = async_tar::Builder::new(&mut bytes);
517 let mut header = async_tar::Header::new_gnu();
518 header.set_size(binary_contents.len() as u64);
519 archive
520 .append_data(&mut header, "gleam", binary_contents.as_bytes())
521 .await
522 .unwrap();
523 archive.into_inner().await.unwrap();
524 let mut gzipped_bytes = Vec::new();
525 let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
526 encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
527 Ok(Response::new(gzipped_bytes.into()))
528 } else {
529 Ok(Response::builder().status(404).body("not found".into())?)
530 }
531 }
532 }
533 });
534
535 let extension_store = cx.new_model(|cx| {
536 ExtensionStore::new(
537 extensions_dir.clone(),
538 Some(cache_dir),
539 fs.clone(),
540 http_client.clone(),
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 // Simulate a new version of the language server being released
621 language_server_version.lock().version = "v2.0.0".into();
622 language_server_version.lock().binary_contents = "the-new-binary-contents".into();
623 language_server_version.lock().http_request_count = 0;
624
625 // Start a new instance of the language server.
626 project.update(cx, |project, cx| {
627 project.restart_language_servers_for_buffers([buffer.clone()], cx)
628 });
629
630 // The extension has cached the binary path, and does not attempt
631 // to reinstall it.
632 let fake_server = fake_servers.next().await.unwrap();
633 assert_eq!(fake_server.binary.path, expected_server_path);
634 assert_eq!(
635 fs.load(&expected_server_path).await.unwrap(),
636 expected_binary_contents
637 );
638 assert_eq!(language_server_version.lock().http_request_count, 0);
639
640 // Reload the extension, clearing its cache.
641 // Start a new instance of the language server.
642 extension_store
643 .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
644 .await;
645
646 cx.executor().run_until_parked();
647 project.update(cx, |project, cx| {
648 project.restart_language_servers_for_buffers([buffer.clone()], cx)
649 });
650
651 // The extension re-fetches the latest version of the language server.
652 let fake_server = fake_servers.next().await.unwrap();
653 let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
654 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
655 assert_eq!(fake_server.binary.path, new_expected_server_path);
656 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
657 assert_eq!(
658 fs.load(&new_expected_server_path).await.unwrap(),
659 expected_binary_contents
660 );
661
662 // The old language server directory has been cleaned up.
663 assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
664}
665
666fn init_test(cx: &mut TestAppContext) {
667 cx.update(|cx| {
668 let store = SettingsStore::test(cx);
669 cx.set_global(store);
670 theme::init(theme::LoadThemes::JustBase, cx);
671 Project::init_settings(cx);
672 language::init(cx);
673 });
674}