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