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 schema_version: 0,
149 description: None,
150 authors: Vec::new(),
151 repository: None,
152 themes: Default::default(),
153 lib: Default::default(),
154 languages: vec!["languages/erb".into(), "languages/ruby".into()],
155 grammars: [
156 ("embedded_template".into(), GrammarManifestEntry::default()),
157 ("ruby".into(), GrammarManifestEntry::default()),
158 ]
159 .into_iter()
160 .collect(),
161 language_servers: BTreeMap::default(),
162 }),
163 dev: false,
164 },
165 ),
166 (
167 "zed-monokai".into(),
168 ExtensionIndexEntry {
169 manifest: Arc::new(ExtensionManifest {
170 id: "zed-monokai".into(),
171 name: "Zed Monokai".into(),
172 version: "2.0.0".into(),
173 schema_version: 0,
174 description: None,
175 authors: vec![],
176 repository: None,
177 themes: vec![
178 "themes/monokai-pro.json".into(),
179 "themes/monokai.json".into(),
180 ],
181 lib: Default::default(),
182 languages: Default::default(),
183 grammars: BTreeMap::default(),
184 language_servers: BTreeMap::default(),
185 }),
186 dev: false,
187 },
188 ),
189 ]
190 .into_iter()
191 .collect(),
192 languages: [
193 (
194 "ERB".into(),
195 ExtensionIndexLanguageEntry {
196 extension: "zed-ruby".into(),
197 path: "languages/erb".into(),
198 grammar: Some("embedded_template".into()),
199 matcher: LanguageMatcher {
200 path_suffixes: vec!["erb".into()],
201 first_line_pattern: None,
202 },
203 },
204 ),
205 (
206 "Ruby".into(),
207 ExtensionIndexLanguageEntry {
208 extension: "zed-ruby".into(),
209 path: "languages/ruby".into(),
210 grammar: Some("ruby".into()),
211 matcher: LanguageMatcher {
212 path_suffixes: vec!["rb".into()],
213 first_line_pattern: None,
214 },
215 },
216 ),
217 ]
218 .into_iter()
219 .collect(),
220 themes: [
221 (
222 "Monokai Dark".into(),
223 ExtensionIndexThemeEntry {
224 extension: "zed-monokai".into(),
225 path: "themes/monokai.json".into(),
226 },
227 ),
228 (
229 "Monokai Light".into(),
230 ExtensionIndexThemeEntry {
231 extension: "zed-monokai".into(),
232 path: "themes/monokai.json".into(),
233 },
234 ),
235 (
236 "Monokai Pro Dark".into(),
237 ExtensionIndexThemeEntry {
238 extension: "zed-monokai".into(),
239 path: "themes/monokai-pro.json".into(),
240 },
241 ),
242 (
243 "Monokai Pro Light".into(),
244 ExtensionIndexThemeEntry {
245 extension: "zed-monokai".into(),
246 path: "themes/monokai-pro.json".into(),
247 },
248 ),
249 ]
250 .into_iter()
251 .collect(),
252 };
253
254 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
255 let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
256 let node_runtime = FakeNodeRuntime::new();
257
258 let store = cx.new_model(|cx| {
259 ExtensionStore::new(
260 PathBuf::from("/the-extension-dir"),
261 None,
262 fs.clone(),
263 http_client.clone(),
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: 0,
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 node_runtime.clone(),
384 language_registry.clone(),
385 theme_registry.clone(),
386 cx,
387 )
388 });
389
390 cx.executor().run_until_parked();
391 store.read_with(cx, |store, _| {
392 assert_eq!(store.extension_index, expected_index);
393 assert_eq!(
394 language_registry.language_names(),
395 ["ERB", "Plain Text", "Ruby"]
396 );
397 assert_eq!(
398 language_registry.grammar_names(),
399 ["embedded_template".into(), "ruby".into()]
400 );
401 assert_eq!(
402 theme_registry.list_names(false),
403 [
404 "Gruvbox",
405 "Monokai Dark",
406 "Monokai Light",
407 "Monokai Pro Dark",
408 "Monokai Pro Light",
409 "One Dark",
410 ]
411 );
412
413 // The on-disk manifest limits the number of FS calls that need to be made
414 // on startup.
415 assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
416 assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
417 });
418
419 store.update(cx, |store, cx| {
420 store.uninstall_extension("zed-ruby".into(), cx)
421 });
422
423 cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
424 expected_index.extensions.remove("zed-ruby");
425 expected_index.languages.remove("Ruby");
426 expected_index.languages.remove("ERB");
427
428 store.read_with(cx, |store, _| {
429 assert_eq!(store.extension_index, expected_index);
430 assert_eq!(language_registry.language_names(), ["Plain Text"]);
431 assert_eq!(language_registry.grammar_names(), []);
432 });
433}
434
435#[gpui::test]
436async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
437 init_test(cx);
438 cx.executor().allow_parking();
439
440 let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
441 .parent()
442 .unwrap()
443 .parent()
444 .unwrap();
445 let cache_dir = root_dir.join("target");
446 let gleam_extension_dir = root_dir.join("extensions").join("gleam");
447
448 let fs = Arc::new(RealFs);
449 let extensions_dir = temp_tree(json!({
450 "installed": {},
451 "work": {}
452 }));
453 let project_dir = temp_tree(json!({
454 "test.gleam": ""
455 }));
456
457 let extensions_dir = extensions_dir.path().canonicalize().unwrap();
458 let project_dir = project_dir.path().canonicalize().unwrap();
459
460 let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
461
462 let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
463 let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
464 let node_runtime = FakeNodeRuntime::new();
465
466 let mut status_updates = language_registry.language_server_binary_statuses();
467
468 struct FakeLanguageServerVersion {
469 version: String,
470 binary_contents: String,
471 http_request_count: usize,
472 }
473
474 let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
475 version: "v1.2.3".into(),
476 binary_contents: "the-binary-contents".into(),
477 http_request_count: 0,
478 }));
479
480 let http_client = FakeHttpClient::create({
481 let language_server_version = language_server_version.clone();
482 move |request| {
483 let language_server_version = language_server_version.clone();
484 async move {
485 language_server_version.lock().http_request_count += 1;
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 Ok(Response::new(
496 json!([
497 {
498 "tag_name": version,
499 "prerelease": false,
500 "tarball_url": "",
501 "zipball_url": "",
502 "assets": [
503 {
504 "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
505 "browser_download_url": asset_download_uri
506 }
507 ]
508 }
509 ])
510 .to_string()
511 .into(),
512 ))
513 } else if uri == asset_download_uri {
514 let mut bytes = Vec::<u8>::new();
515 let mut archive = async_tar::Builder::new(&mut bytes);
516 let mut header = async_tar::Header::new_gnu();
517 header.set_size(binary_contents.len() as u64);
518 archive
519 .append_data(&mut header, "gleam", binary_contents.as_bytes())
520 .await
521 .unwrap();
522 archive.into_inner().await.unwrap();
523 let mut gzipped_bytes = Vec::new();
524 let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
525 encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
526 Ok(Response::new(gzipped_bytes.into()))
527 } else {
528 Ok(Response::builder().status(404).body("not found".into())?)
529 }
530 }
531 }
532 });
533
534 let extension_store = cx.new_model(|cx| {
535 ExtensionStore::new(
536 extensions_dir.clone(),
537 Some(cache_dir),
538 fs.clone(),
539 http_client.clone(),
540 node_runtime,
541 language_registry.clone(),
542 theme_registry.clone(),
543 cx,
544 )
545 });
546
547 // Ensure that debounces fire.
548 let mut events = cx.events(&extension_store);
549 let executor = cx.executor();
550 let _task = cx.executor().spawn(async move {
551 while let Some(event) = events.next().await {
552 match event {
553 crate::Event::StartedReloading => {
554 executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
555 }
556 _ => (),
557 }
558 }
559 });
560
561 extension_store
562 .update(cx, |store, cx| {
563 store.install_dev_extension(gleam_extension_dir.clone(), cx)
564 })
565 .await
566 .unwrap();
567
568 let mut fake_servers = language_registry.fake_language_servers("Gleam");
569
570 let buffer = project
571 .update(cx, |project, cx| {
572 project.open_local_buffer(project_dir.join("test.gleam"), cx)
573 })
574 .await
575 .unwrap();
576
577 let fake_server = fake_servers.next().await.unwrap();
578 let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
579 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
580
581 assert_eq!(fake_server.binary.path, expected_server_path);
582 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
583 assert_eq!(
584 fs.load(&expected_server_path).await.unwrap(),
585 expected_binary_contents
586 );
587 assert_eq!(language_server_version.lock().http_request_count, 2);
588 assert_eq!(
589 [
590 status_updates.next().await.unwrap(),
591 status_updates.next().await.unwrap(),
592 status_updates.next().await.unwrap(),
593 ],
594 [
595 (
596 LanguageServerName("gleam".into()),
597 LanguageServerBinaryStatus::CheckingForUpdate
598 ),
599 (
600 LanguageServerName("gleam".into()),
601 LanguageServerBinaryStatus::Downloading
602 ),
603 (
604 LanguageServerName("gleam".into()),
605 LanguageServerBinaryStatus::Downloaded
606 )
607 ]
608 );
609
610 // Simulate a new version of the language server being released
611 language_server_version.lock().version = "v2.0.0".into();
612 language_server_version.lock().binary_contents = "the-new-binary-contents".into();
613 language_server_version.lock().http_request_count = 0;
614
615 // Start a new instance of the language server.
616 project.update(cx, |project, cx| {
617 project.restart_language_servers_for_buffers([buffer.clone()], cx)
618 });
619
620 // The extension has cached the binary path, and does not attempt
621 // to reinstall it.
622 let fake_server = fake_servers.next().await.unwrap();
623 assert_eq!(fake_server.binary.path, expected_server_path);
624 assert_eq!(
625 fs.load(&expected_server_path).await.unwrap(),
626 expected_binary_contents
627 );
628 assert_eq!(language_server_version.lock().http_request_count, 0);
629
630 // Reload the extension, clearing its cache.
631 // Start a new instance of the language server.
632 extension_store
633 .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
634 .await;
635
636 cx.executor().run_until_parked();
637 project.update(cx, |project, cx| {
638 project.restart_language_servers_for_buffers([buffer.clone()], cx)
639 });
640
641 // The extension re-fetches the latest version of the language server.
642 let fake_server = fake_servers.next().await.unwrap();
643 let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
644 let expected_binary_contents = language_server_version.lock().binary_contents.clone();
645 assert_eq!(fake_server.binary.path, new_expected_server_path);
646 assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
647 assert_eq!(
648 fs.load(&new_expected_server_path).await.unwrap(),
649 expected_binary_contents
650 );
651
652 // The old language server directory has been cleaned up.
653 assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
654}
655
656fn init_test(cx: &mut TestAppContext) {
657 cx.update(|cx| {
658 let store = SettingsStore::test(cx);
659 cx.set_global(store);
660 theme::init(theme::LoadThemes::JustBase, cx);
661 Project::init_settings(cx);
662 language::init(cx);
663 });
664}