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