extension_store_test.rs

  1use crate::extension_manifest::SchemaVersion;
  2use crate::extension_settings::ExtensionSettings;
  3use crate::{
  4    Event, ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry,
  5    ExtensionIndexThemeEntry, ExtensionManifest, ExtensionStore, GrammarManifestEntry,
  6    RELOAD_DEBOUNCE_DURATION,
  7};
  8use async_compression::futures::bufread::GzipEncoder;
  9use collections::BTreeMap;
 10use fs::{FakeFs, Fs, RealFs};
 11use futures::{io::BufReader, AsyncReadExt, StreamExt};
 12use gpui::{Context, TestAppContext};
 13use http::{FakeHttpClient, Response};
 14use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
 15use node_runtime::FakeNodeRuntime;
 16use parking_lot::Mutex;
 17use project::Project;
 18use serde_json::json;
 19use settings::{Settings as _, SettingsStore};
 20use std::{
 21    ffi::OsString,
 22    path::{Path, PathBuf},
 23    sync::Arc,
 24};
 25use theme::ThemeRegistry;
 26use util::test::temp_tree;
 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    init_test(cx);
 39
 40    let fs = FakeFs::new(cx.executor());
 41    let http_client = FakeHttpClient::with_200_response();
 42
 43    fs.insert_tree(
 44        "/the-extension-dir",
 45        json!({
 46            "installed": {
 47                "zed-monokai": {
 48                    "extension.json": r#"{
 49                        "id": "zed-monokai",
 50                        "name": "Zed Monokai",
 51                        "version": "2.0.0",
 52                        "themes": {
 53                            "Monokai Dark": "themes/monokai.json",
 54                            "Monokai Light": "themes/monokai.json",
 55                            "Monokai Pro Dark": "themes/monokai-pro.json",
 56                            "Monokai Pro Light": "themes/monokai-pro.json"
 57                        }
 58                    }"#,
 59                    "themes": {
 60                        "monokai.json": r#"{
 61                            "name": "Monokai",
 62                            "author": "Someone",
 63                            "themes": [
 64                                {
 65                                    "name": "Monokai Dark",
 66                                    "appearance": "dark",
 67                                    "style": {}
 68                                },
 69                                {
 70                                    "name": "Monokai Light",
 71                                    "appearance": "light",
 72                                    "style": {}
 73                                }
 74                            ]
 75                        }"#,
 76                        "monokai-pro.json": r#"{
 77                            "name": "Monokai Pro",
 78                            "author": "Someone",
 79                            "themes": [
 80                                {
 81                                    "name": "Monokai Pro Dark",
 82                                    "appearance": "dark",
 83                                    "style": {}
 84                                },
 85                                {
 86                                    "name": "Monokai Pro Light",
 87                                    "appearance": "light",
 88                                    "style": {}
 89                                }
 90                            ]
 91                        }"#,
 92                    }
 93                },
 94                "zed-ruby": {
 95                    "extension.json": r#"{
 96                        "id": "zed-ruby",
 97                        "name": "Zed Ruby",
 98                        "version": "1.0.0",
 99                        "grammars": {
100                            "ruby": "grammars/ruby.wasm",
101                            "embedded_template": "grammars/embedded_template.wasm"
102                        },
103                        "languages": {
104                            "ruby": "languages/ruby",
105                            "erb": "languages/erb"
106                        }
107                    }"#,
108                    "grammars": {
109                        "ruby.wasm": "",
110                        "embedded_template.wasm": "",
111                    },
112                    "languages": {
113                        "ruby": {
114                            "config.toml": r#"
115                                name = "Ruby"
116                                grammar = "ruby"
117                                path_suffixes = ["rb"]
118                            "#,
119                            "highlights.scm": "",
120                        },
121                        "erb": {
122                            "config.toml": r#"
123                                name = "ERB"
124                                grammar = "embedded_template"
125                                path_suffixes = ["erb"]
126                            "#,
127                            "highlights.scm": "",
128                        }
129                    },
130                }
131            }
132        }),
133    )
134    .await;
135
136    let mut expected_index = ExtensionIndex {
137        extensions: [
138            (
139                "zed-ruby".into(),
140                ExtensionIndexEntry {
141                    manifest: Arc::new(ExtensionManifest {
142                        id: "zed-ruby".into(),
143                        name: "Zed Ruby".into(),
144                        version: "1.0.0".into(),
145                        schema_version: SchemaVersion::ZERO,
146                        description: None,
147                        authors: Vec::new(),
148                        repository: None,
149                        themes: Default::default(),
150                        lib: Default::default(),
151                        languages: vec!["languages/erb".into(), "languages/ruby".into()],
152                        grammars: [
153                            ("embedded_template".into(), GrammarManifestEntry::default()),
154                            ("ruby".into(), GrammarManifestEntry::default()),
155                        ]
156                        .into_iter()
157                        .collect(),
158                        language_servers: BTreeMap::default(),
159                    }),
160                    dev: false,
161                },
162            ),
163            (
164                "zed-monokai".into(),
165                ExtensionIndexEntry {
166                    manifest: Arc::new(ExtensionManifest {
167                        id: "zed-monokai".into(),
168                        name: "Zed Monokai".into(),
169                        version: "2.0.0".into(),
170                        schema_version: SchemaVersion::ZERO,
171                        description: None,
172                        authors: vec![],
173                        repository: None,
174                        themes: vec![
175                            "themes/monokai-pro.json".into(),
176                            "themes/monokai.json".into(),
177                        ],
178                        lib: Default::default(),
179                        languages: Default::default(),
180                        grammars: BTreeMap::default(),
181                        language_servers: BTreeMap::default(),
182                    }),
183                    dev: false,
184                },
185            ),
186        ]
187        .into_iter()
188        .collect(),
189        languages: [
190            (
191                "ERB".into(),
192                ExtensionIndexLanguageEntry {
193                    extension: "zed-ruby".into(),
194                    path: "languages/erb".into(),
195                    grammar: Some("embedded_template".into()),
196                    matcher: LanguageMatcher {
197                        path_suffixes: vec!["erb".into()],
198                        first_line_pattern: None,
199                    },
200                },
201            ),
202            (
203                "Ruby".into(),
204                ExtensionIndexLanguageEntry {
205                    extension: "zed-ruby".into(),
206                    path: "languages/ruby".into(),
207                    grammar: Some("ruby".into()),
208                    matcher: LanguageMatcher {
209                        path_suffixes: vec!["rb".into()],
210                        first_line_pattern: None,
211                    },
212                },
213            ),
214        ]
215        .into_iter()
216        .collect(),
217        themes: [
218            (
219                "Monokai Dark".into(),
220                ExtensionIndexThemeEntry {
221                    extension: "zed-monokai".into(),
222                    path: "themes/monokai.json".into(),
223                },
224            ),
225            (
226                "Monokai Light".into(),
227                ExtensionIndexThemeEntry {
228                    extension: "zed-monokai".into(),
229                    path: "themes/monokai.json".into(),
230                },
231            ),
232            (
233                "Monokai Pro Dark".into(),
234                ExtensionIndexThemeEntry {
235                    extension: "zed-monokai".into(),
236                    path: "themes/monokai-pro.json".into(),
237                },
238            ),
239            (
240                "Monokai Pro Light".into(),
241                ExtensionIndexThemeEntry {
242                    extension: "zed-monokai".into(),
243                    path: "themes/monokai-pro.json".into(),
244                },
245            ),
246        ]
247        .into_iter()
248        .collect(),
249    };
250
251    let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
252    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
253    let node_runtime = FakeNodeRuntime::new();
254
255    let store = cx.new_model(|cx| {
256        ExtensionStore::new(
257            PathBuf::from("/the-extension-dir"),
258            None,
259            fs.clone(),
260            http_client.clone(),
261            None,
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                schema_version: SchemaVersion::ZERO,
328                description: None,
329                authors: vec![],
330                repository: None,
331                themes: vec!["themes/gruvbox.json".into()],
332                lib: Default::default(),
333                languages: Default::default(),
334                grammars: BTreeMap::default(),
335                language_servers: BTreeMap::default(),
336            }),
337            dev: false,
338        },
339    );
340    expected_index.themes.insert(
341        "Gruvbox".into(),
342        ExtensionIndexThemeEntry {
343            extension: "zed-gruvbox".into(),
344            path: "themes/gruvbox.json".into(),
345        },
346    );
347
348    let _ = store.update(cx, |store, cx| store.reload(None, cx));
349
350    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
351    store.read_with(cx, |store, _| {
352        let index = &store.extension_index;
353        assert_eq!(index.extensions, expected_index.extensions);
354        assert_eq!(index.languages, expected_index.languages);
355        assert_eq!(index.themes, expected_index.themes);
356
357        assert_eq!(
358            theme_registry.list_names(false),
359            [
360                "Gruvbox",
361                "Monokai Dark",
362                "Monokai Light",
363                "Monokai Pro Dark",
364                "Monokai Pro Light",
365                "One Dark",
366            ]
367        );
368    });
369
370    let prev_fs_metadata_call_count = fs.metadata_call_count();
371    let prev_fs_read_dir_call_count = fs.read_dir_call_count();
372
373    // Create new extension store, as if Zed were restarting.
374    drop(store);
375    let store = cx.new_model(|cx| {
376        ExtensionStore::new(
377            PathBuf::from("/the-extension-dir"),
378            None,
379            fs.clone(),
380            http_client.clone(),
381            None,
382            node_runtime.clone(),
383            language_registry.clone(),
384            theme_registry.clone(),
385            cx,
386        )
387    });
388
389    cx.executor().run_until_parked();
390    store.read_with(cx, |store, _| {
391        assert_eq!(store.extension_index, expected_index);
392        assert_eq!(
393            language_registry.language_names(),
394            ["ERB", "Plain Text", "Ruby"]
395        );
396        assert_eq!(
397            language_registry.grammar_names(),
398            ["embedded_template".into(), "ruby".into()]
399        );
400        assert_eq!(
401            theme_registry.list_names(false),
402            [
403                "Gruvbox",
404                "Monokai Dark",
405                "Monokai Light",
406                "Monokai Pro Dark",
407                "Monokai Pro Light",
408                "One Dark",
409            ]
410        );
411
412        // The on-disk manifest limits the number of FS calls that need to be made
413        // on startup.
414        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
415        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
416    });
417
418    store.update(cx, |store, cx| {
419        store.uninstall_extension("zed-ruby".into(), cx)
420    });
421
422    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
423    expected_index.extensions.remove("zed-ruby");
424    expected_index.languages.remove("Ruby");
425    expected_index.languages.remove("ERB");
426
427    store.read_with(cx, |store, _| {
428        assert_eq!(store.extension_index, expected_index);
429        assert_eq!(language_registry.language_names(), ["Plain Text"]);
430        assert_eq!(language_registry.grammar_names(), []);
431    });
432}
433
434#[gpui::test]
435async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
436    init_test(cx);
437    cx.executor().allow_parking();
438
439    let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
440        .parent()
441        .unwrap()
442        .parent()
443        .unwrap();
444    let cache_dir = root_dir.join("target");
445    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
446
447    let fs = Arc::new(RealFs::default());
448    let extensions_dir = temp_tree(json!({
449        "installed": {},
450        "work": {}
451    }));
452    let project_dir = temp_tree(json!({
453        "test.gleam": ""
454    }));
455
456    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
457    let project_dir = project_dir.path().canonicalize().unwrap();
458
459    let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
460
461    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
462    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
463    let node_runtime = FakeNodeRuntime::new();
464
465    let mut status_updates = language_registry.language_server_binary_statuses();
466
467    struct FakeLanguageServerVersion {
468        version: String,
469        binary_contents: String,
470        http_request_count: usize,
471    }
472
473    let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
474        version: "v1.2.3".into(),
475        binary_contents: "the-binary-contents".into(),
476        http_request_count: 0,
477    }));
478
479    let http_client = FakeHttpClient::create({
480        let language_server_version = language_server_version.clone();
481        move |request| {
482            let language_server_version = language_server_version.clone();
483            async move {
484                let version = language_server_version.lock().version.clone();
485                let binary_contents = language_server_version.lock().binary_contents.clone();
486
487                let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
488                let asset_download_uri =
489                    format!("https://fake-download.example.com/gleam-{version}");
490
491                let uri = request.uri().to_string();
492                if uri == github_releases_uri {
493                    language_server_version.lock().http_request_count += 1;
494                    Ok(Response::new(
495                        json!([
496                            {
497                                "tag_name": version,
498                                "prerelease": false,
499                                "tarball_url": "",
500                                "zipball_url": "",
501                                "assets": [
502                                    {
503                                        "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
504                                        "browser_download_url": asset_download_uri
505                                    }
506                                ]
507                            }
508                        ])
509                        .to_string()
510                        .into(),
511                    ))
512                } else if uri == asset_download_uri {
513                    language_server_version.lock().http_request_count += 1;
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            None,
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    // The extension creates custom labels for completion items.
621    fake_server.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
622        Ok(Some(lsp::CompletionResponse::Array(vec![
623            lsp::CompletionItem {
624                label: "foo".into(),
625                kind: Some(lsp::CompletionItemKind::FUNCTION),
626                detail: Some("fn() -> Result(Nil, Error)".into()),
627                ..Default::default()
628            },
629            lsp::CompletionItem {
630                label: "bar.baz".into(),
631                kind: Some(lsp::CompletionItemKind::FUNCTION),
632                detail: Some("fn(List(a)) -> a".into()),
633                ..Default::default()
634            },
635            lsp::CompletionItem {
636                label: "Quux".into(),
637                kind: Some(lsp::CompletionItemKind::CONSTRUCTOR),
638                detail: Some("fn(String) -> T".into()),
639                ..Default::default()
640            },
641            lsp::CompletionItem {
642                label: "my_string".into(),
643                kind: Some(lsp::CompletionItemKind::CONSTANT),
644                detail: Some("String".into()),
645                ..Default::default()
646            },
647        ])))
648    });
649
650    let completion_labels = project
651        .update(cx, |project, cx| project.completions(&buffer, 0, cx))
652        .await
653        .unwrap()
654        .into_iter()
655        .map(|c| c.label.text)
656        .collect::<Vec<_>>();
657    assert_eq!(
658        completion_labels,
659        [
660            "foo: fn() -> Result(Nil, Error)".to_string(),
661            "bar.baz: fn(List(a)) -> a".to_string(),
662            "Quux: fn(String) -> T".to_string(),
663            "my_string: String".to_string(),
664        ]
665    );
666
667    // Simulate a new version of the language server being released
668    language_server_version.lock().version = "v2.0.0".into();
669    language_server_version.lock().binary_contents = "the-new-binary-contents".into();
670    language_server_version.lock().http_request_count = 0;
671
672    // Start a new instance of the language server.
673    project.update(cx, |project, cx| {
674        project.restart_language_servers_for_buffers([buffer.clone()], cx)
675    });
676
677    // The extension has cached the binary path, and does not attempt
678    // to reinstall it.
679    let fake_server = fake_servers.next().await.unwrap();
680    assert_eq!(fake_server.binary.path, expected_server_path);
681    assert_eq!(
682        fs.load(&expected_server_path).await.unwrap(),
683        expected_binary_contents
684    );
685    assert_eq!(language_server_version.lock().http_request_count, 0);
686
687    // Reload the extension, clearing its cache.
688    // Start a new instance of the language server.
689    extension_store
690        .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
691        .await;
692
693    cx.executor().run_until_parked();
694    project.update(cx, |project, cx| {
695        project.restart_language_servers_for_buffers([buffer.clone()], cx)
696    });
697
698    // The extension re-fetches the latest version of the language server.
699    let fake_server = fake_servers.next().await.unwrap();
700    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
701    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
702    assert_eq!(fake_server.binary.path, new_expected_server_path);
703    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
704    assert_eq!(
705        fs.load(&new_expected_server_path).await.unwrap(),
706        expected_binary_contents
707    );
708
709    // The old language server directory has been cleaned up.
710    assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
711}
712
713fn init_test(cx: &mut TestAppContext) {
714    cx.update(|cx| {
715        let store = SettingsStore::test(cx);
716        cx.set_global(store);
717        theme::init(theme::LoadThemes::JustBase, cx);
718        Project::init_settings(cx);
719        ExtensionSettings::register(cx);
720        language::init(cx);
721    });
722}