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 None,
266 node_runtime.clone(),
267 language_registry.clone(),
268 theme_registry.clone(),
269 cx,
270 )
271 });
272
273 cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
274 store.read_with(cx, |store, _| {
275 let index = &store.extension_index;
276 assert_eq!(index.extensions, expected_index.extensions);
277 assert_eq!(index.languages, expected_index.languages);
278 assert_eq!(index.themes, expected_index.themes);
279
280 assert_eq!(
281 language_registry.language_names(),
282 ["ERB", "Plain Text", "Ruby"]
283 );
284 assert_eq!(
285 theme_registry.list_names(false),
286 [
287 "Monokai Dark",
288 "Monokai Light",
289 "Monokai Pro Dark",
290 "Monokai Pro Light",
291 "One Dark",
292 ]
293 );
294 });
295
296 fs.insert_tree(
297 "/the-extension-dir/installed/zed-gruvbox",
298 json!({
299 "extension.json": r#"{
300 "id": "zed-gruvbox",
301 "name": "Zed Gruvbox",
302 "version": "1.0.0",
303 "themes": {
304 "Gruvbox": "themes/gruvbox.json"
305 }
306 }"#,
307 "themes": {
308 "gruvbox.json": r#"{
309 "name": "Gruvbox",
310 "author": "Someone Else",
311 "themes": [
312 {
313 "name": "Gruvbox",
314 "appearance": "dark",
315 "style": {}
316 }
317 ]
318 }"#,
319 }
320 }),
321 )
322 .await;
323
324 expected_index.extensions.insert(
325 "zed-gruvbox".into(),
326 ExtensionIndexEntry {
327 manifest: Arc::new(ExtensionManifest {
328 id: "zed-gruvbox".into(),
329 name: "Zed Gruvbox".into(),
330 version: "1.0.0".into(),
331 schema_version: 0,
332 description: None,
333 authors: vec![],
334 repository: None,
335 themes: vec!["themes/gruvbox.json".into()],
336 lib: Default::default(),
337 languages: Default::default(),
338 grammars: BTreeMap::default(),
339 language_servers: BTreeMap::default(),
340 }),
341 dev: false,
342 },
343 );
344 expected_index.themes.insert(
345 "Gruvbox".into(),
346 ExtensionIndexThemeEntry {
347 extension: "zed-gruvbox".into(),
348 path: "themes/gruvbox.json".into(),
349 },
350 );
351
352 let _ = store.update(cx, |store, cx| store.reload(None, cx));
353
354 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
355 store.read_with(cx, |store, _| {
356 let index = &store.extension_index;
357 assert_eq!(index.extensions, expected_index.extensions);
358 assert_eq!(index.languages, expected_index.languages);
359 assert_eq!(index.themes, expected_index.themes);
360
361 assert_eq!(
362 theme_registry.list_names(false),
363 [
364 "Gruvbox",
365 "Monokai Dark",
366 "Monokai Light",
367 "Monokai Pro Dark",
368 "Monokai Pro Light",
369 "One Dark",
370 ]
371 );
372 });
373
374 let prev_fs_metadata_call_count = fs.metadata_call_count();
375 let prev_fs_read_dir_call_count = fs.read_dir_call_count();
376
377 // Create new extension store, as if Zed were restarting.
378 drop(store);
379 let store = cx.new_model(|cx| {
380 ExtensionStore::new(
381 PathBuf::from("/the-extension-dir"),
382 None,
383 fs.clone(),
384 http_client.clone(),
385 None,
386 node_runtime.clone(),
387 language_registry.clone(),
388 theme_registry.clone(),
389 cx,
390 )
391 });
392
393 cx.executor().run_until_parked();
394 store.read_with(cx, |store, _| {
395 assert_eq!(store.extension_index, expected_index);
396 assert_eq!(
397 language_registry.language_names(),
398 ["ERB", "Plain Text", "Ruby"]
399 );
400 assert_eq!(
401 language_registry.grammar_names(),
402 ["embedded_template".into(), "ruby".into()]
403 );
404 assert_eq!(
405 theme_registry.list_names(false),
406 [
407 "Gruvbox",
408 "Monokai Dark",
409 "Monokai Light",
410 "Monokai Pro Dark",
411 "Monokai Pro Light",
412 "One Dark",
413 ]
414 );
415
416 // The on-disk manifest limits the number of FS calls that need to be made
417 // on startup.
418 assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
419 assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
420 });
421
422 store.update(cx, |store, cx| {
423 store.uninstall_extension("zed-ruby".into(), cx)
424 });
425
426 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
427 expected_index.extensions.remove("zed-ruby");
428 expected_index.languages.remove("Ruby");
429 expected_index.languages.remove("ERB");
430
431 store.read_with(cx, |store, _| {
432 assert_eq!(store.extension_index, expected_index);
433 assert_eq!(language_registry.language_names(), ["Plain Text"]);
434 assert_eq!(language_registry.grammar_names(), []);
435 });
436}
437
438#[gpui::test]
439async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
440 init_test(cx);
441 cx.executor().allow_parking();
442
443 let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
444 .parent()
445 .unwrap()
446 .parent()
447 .unwrap();
448 let cache_dir = root_dir.join("target");
449 let gleam_extension_dir = root_dir.join("extensions").join("gleam");
450
451 let fs = Arc::new(RealFs);
452 let extensions_dir = temp_tree(json!({
453 "installed": {},
454 "work": {}
455 }));
456 let project_dir = temp_tree(json!({
457 "test.gleam": ""
458 }));
459
460 let extensions_dir = extensions_dir.path().canonicalize().unwrap();
461 let project_dir = project_dir.path().canonicalize().unwrap();
462
463 let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
464
465 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
466 let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
467 let node_runtime = FakeNodeRuntime::new();
468
469 let mut status_updates = language_registry.language_server_binary_statuses();
470
471 struct FakeLanguageServerVersion {
472 version: String,
473 binary_contents: String,
474 http_request_count: usize,
475 }
476
477 let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
478 version: "v1.2.3".into(),
479 binary_contents: "the-binary-contents".into(),
480 http_request_count: 0,
481 }));
482
483 let http_client = FakeHttpClient::create({
484 let language_server_version = language_server_version.clone();
485 move |request| {
486 let language_server_version = language_server_version.clone();
487 async move {
488 language_server_version.lock().http_request_count += 1;
489 let version = language_server_version.lock().version.clone();
490 let binary_contents = language_server_version.lock().binary_contents.clone();
491
492 let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
493 let asset_download_uri =
494 format!("https://fake-download.example.com/gleam-{version}");
495
496 let uri = request.uri().to_string();
497 if uri == github_releases_uri {
498 Ok(Response::new(
499 json!([
500 {
501 "tag_name": version,
502 "prerelease": false,
503 "tarball_url": "",
504 "zipball_url": "",
505 "assets": [
506 {
507 "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
508 "browser_download_url": asset_download_uri
509 }
510 ]
511 }
512 ])
513 .to_string()
514 .into(),
515 ))
516 } else if uri == asset_download_uri {
517 let mut bytes = Vec::<u8>::new();
518 let mut archive = async_tar::Builder::new(&mut bytes);
519 let mut header = async_tar::Header::new_gnu();
520 header.set_size(binary_contents.len() as u64);
521 archive
522 .append_data(&mut header, "gleam", binary_contents.as_bytes())
523 .await
524 .unwrap();
525 archive.into_inner().await.unwrap();
526 let mut gzipped_bytes = Vec::new();
527 let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
528 encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
529 Ok(Response::new(gzipped_bytes.into()))
530 } else {
531 Ok(Response::builder().status(404).body("not found".into())?)
532 }
533 }
534 }
535 });
536
537 let extension_store = cx.new_model(|cx| {
538 ExtensionStore::new(
539 extensions_dir.clone(),
540 Some(cache_dir),
541 fs.clone(),
542 http_client.clone(),
543 None,
544 node_runtime,
545 language_registry.clone(),
546 theme_registry.clone(),
547 cx,
548 )
549 });
550
551 // Ensure that debounces fire.
552 let mut events = cx.events(&extension_store);
553 let executor = cx.executor();
554 let _task = cx.executor().spawn(async move {
555 while let Some(event) = events.next().await {
556 match event {
557 crate::Event::StartedReloading => {
558 executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
559 }
560 _ => (),
561 }
562 }
563 });
564
565 extension_store.update(cx, |_, cx| {
566 cx.subscribe(&extension_store, |_, _, event, _| {
567 if matches!(event, Event::ExtensionFailedToLoad(_)) {
568 panic!("extension failed to load");
569 }
570 })
571 .detach();
572 });
573
574 extension_store
575 .update(cx, |store, cx| {
576 store.install_dev_extension(gleam_extension_dir.clone(), cx)
577 })
578 .await
579 .unwrap();
580
581 let mut fake_servers = language_registry.fake_language_servers("Gleam");
582
583 let buffer = project
584 .update(cx, |project, cx| {
585 project.open_local_buffer(project_dir.join("test.gleam"), cx)
586 })
587 .await
588 .unwrap();
589
590 let fake_server = fake_servers.next().await.unwrap();
591 let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
592 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
593
594 assert_eq!(fake_server.binary.path, expected_server_path);
595 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
596 assert_eq!(
597 fs.load(&expected_server_path).await.unwrap(),
598 expected_binary_contents
599 );
600 assert_eq!(language_server_version.lock().http_request_count, 2);
601 assert_eq!(
602 [
603 status_updates.next().await.unwrap(),
604 status_updates.next().await.unwrap(),
605 status_updates.next().await.unwrap(),
606 ],
607 [
608 (
609 LanguageServerName("gleam".into()),
610 LanguageServerBinaryStatus::CheckingForUpdate
611 ),
612 (
613 LanguageServerName("gleam".into()),
614 LanguageServerBinaryStatus::Downloading
615 ),
616 (
617 LanguageServerName("gleam".into()),
618 LanguageServerBinaryStatus::None
619 )
620 ]
621 );
622
623 // Simulate a new version of the language server being released
624 language_server_version.lock().version = "v2.0.0".into();
625 language_server_version.lock().binary_contents = "the-new-binary-contents".into();
626 language_server_version.lock().http_request_count = 0;
627
628 // Start a new instance of the language server.
629 project.update(cx, |project, cx| {
630 project.restart_language_servers_for_buffers([buffer.clone()], cx)
631 });
632
633 // The extension has cached the binary path, and does not attempt
634 // to reinstall it.
635 let fake_server = fake_servers.next().await.unwrap();
636 assert_eq!(fake_server.binary.path, expected_server_path);
637 assert_eq!(
638 fs.load(&expected_server_path).await.unwrap(),
639 expected_binary_contents
640 );
641 assert_eq!(language_server_version.lock().http_request_count, 0);
642
643 // Reload the extension, clearing its cache.
644 // Start a new instance of the language server.
645 extension_store
646 .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
647 .await;
648
649 cx.executor().run_until_parked();
650 project.update(cx, |project, cx| {
651 project.restart_language_servers_for_buffers([buffer.clone()], cx)
652 });
653
654 // The extension re-fetches the latest version of the language server.
655 let fake_server = fake_servers.next().await.unwrap();
656 let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
657 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
658 assert_eq!(fake_server.binary.path, new_expected_server_path);
659 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
660 assert_eq!(
661 fs.load(&new_expected_server_path).await.unwrap(),
662 expected_binary_contents
663 );
664
665 // The old language server directory has been cleaned up.
666 assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
667}
668
669fn init_test(cx: &mut TestAppContext) {
670 cx.update(|cx| {
671 let store = SettingsStore::test(cx);
672 cx.set_global(store);
673 theme::init(theme::LoadThemes::JustBase, cx);
674 Project::init_settings(cx);
675 language::init(cx);
676 });
677}