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 language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
 14use node_runtime::FakeNodeRuntime;
 15use parking_lot::Mutex;
 16use project::Project;
 17use serde_json::json;
 18use settings::{Settings as _, SettingsStore};
 19use std::{
 20    ffi::OsString,
 21    path::{Path, PathBuf},
 22    sync::Arc,
 23};
 24use theme::ThemeRegistry;
 25use util::{
 26    http::{FakeHttpClient, Response},
 27    test::temp_tree,
 28};
 29
 30#[cfg(test)]
 31#[ctor::ctor]
 32fn init_logger() {
 33    if std::env::var("RUST_LOG").is_ok() {
 34        env_logger::init();
 35    }
 36}
 37
 38#[gpui::test]
 39async fn test_extension_store(cx: &mut TestAppContext) {
 40    init_test(cx);
 41
 42    let fs = FakeFs::new(cx.executor());
 43    let http_client = FakeHttpClient::with_200_response();
 44
 45    fs.insert_tree(
 46        "/the-extension-dir",
 47        json!({
 48            "installed": {
 49                "zed-monokai": {
 50                    "extension.json": r#"{
 51                        "id": "zed-monokai",
 52                        "name": "Zed Monokai",
 53                        "version": "2.0.0",
 54                        "themes": {
 55                            "Monokai Dark": "themes/monokai.json",
 56                            "Monokai Light": "themes/monokai.json",
 57                            "Monokai Pro Dark": "themes/monokai-pro.json",
 58                            "Monokai Pro Light": "themes/monokai-pro.json"
 59                        }
 60                    }"#,
 61                    "themes": {
 62                        "monokai.json": r#"{
 63                            "name": "Monokai",
 64                            "author": "Someone",
 65                            "themes": [
 66                                {
 67                                    "name": "Monokai Dark",
 68                                    "appearance": "dark",
 69                                    "style": {}
 70                                },
 71                                {
 72                                    "name": "Monokai Light",
 73                                    "appearance": "light",
 74                                    "style": {}
 75                                }
 76                            ]
 77                        }"#,
 78                        "monokai-pro.json": r#"{
 79                            "name": "Monokai Pro",
 80                            "author": "Someone",
 81                            "themes": [
 82                                {
 83                                    "name": "Monokai Pro Dark",
 84                                    "appearance": "dark",
 85                                    "style": {}
 86                                },
 87                                {
 88                                    "name": "Monokai Pro Light",
 89                                    "appearance": "light",
 90                                    "style": {}
 91                                }
 92                            ]
 93                        }"#,
 94                    }
 95                },
 96                "zed-ruby": {
 97                    "extension.json": r#"{
 98                        "id": "zed-ruby",
 99                        "name": "Zed Ruby",
100                        "version": "1.0.0",
101                        "grammars": {
102                            "ruby": "grammars/ruby.wasm",
103                            "embedded_template": "grammars/embedded_template.wasm"
104                        },
105                        "languages": {
106                            "ruby": "languages/ruby",
107                            "erb": "languages/erb"
108                        }
109                    }"#,
110                    "grammars": {
111                        "ruby.wasm": "",
112                        "embedded_template.wasm": "",
113                    },
114                    "languages": {
115                        "ruby": {
116                            "config.toml": r#"
117                                name = "Ruby"
118                                grammar = "ruby"
119                                path_suffixes = ["rb"]
120                            "#,
121                            "highlights.scm": "",
122                        },
123                        "erb": {
124                            "config.toml": r#"
125                                name = "ERB"
126                                grammar = "embedded_template"
127                                path_suffixes = ["erb"]
128                            "#,
129                            "highlights.scm": "",
130                        }
131                    },
132                }
133            }
134        }),
135    )
136    .await;
137
138    let mut expected_index = ExtensionIndex {
139        extensions: [
140            (
141                "zed-ruby".into(),
142                ExtensionIndexEntry {
143                    manifest: Arc::new(ExtensionManifest {
144                        id: "zed-ruby".into(),
145                        name: "Zed Ruby".into(),
146                        version: "1.0.0".into(),
147                        schema_version: SchemaVersion::ZERO,
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                        schema_version: SchemaVersion::ZERO,
173                        description: None,
174                        authors: vec![],
175                        repository: None,
176                        themes: vec![
177                            "themes/monokai-pro.json".into(),
178                            "themes/monokai.json".into(),
179                        ],
180                        lib: Default::default(),
181                        languages: Default::default(),
182                        grammars: BTreeMap::default(),
183                        language_servers: BTreeMap::default(),
184                    }),
185                    dev: false,
186                },
187            ),
188        ]
189        .into_iter()
190        .collect(),
191        languages: [
192            (
193                "ERB".into(),
194                ExtensionIndexLanguageEntry {
195                    extension: "zed-ruby".into(),
196                    path: "languages/erb".into(),
197                    grammar: Some("embedded_template".into()),
198                    matcher: LanguageMatcher {
199                        path_suffixes: vec!["erb".into()],
200                        first_line_pattern: None,
201                    },
202                },
203            ),
204            (
205                "Ruby".into(),
206                ExtensionIndexLanguageEntry {
207                    extension: "zed-ruby".into(),
208                    path: "languages/ruby".into(),
209                    grammar: Some("ruby".into()),
210                    matcher: LanguageMatcher {
211                        path_suffixes: vec!["rb".into()],
212                        first_line_pattern: None,
213                    },
214                },
215            ),
216        ]
217        .into_iter()
218        .collect(),
219        themes: [
220            (
221                "Monokai Dark".into(),
222                ExtensionIndexThemeEntry {
223                    extension: "zed-monokai".into(),
224                    path: "themes/monokai.json".into(),
225                },
226            ),
227            (
228                "Monokai Light".into(),
229                ExtensionIndexThemeEntry {
230                    extension: "zed-monokai".into(),
231                    path: "themes/monokai.json".into(),
232                },
233            ),
234            (
235                "Monokai Pro Dark".into(),
236                ExtensionIndexThemeEntry {
237                    extension: "zed-monokai".into(),
238                    path: "themes/monokai-pro.json".into(),
239                },
240            ),
241            (
242                "Monokai Pro Light".into(),
243                ExtensionIndexThemeEntry {
244                    extension: "zed-monokai".into(),
245                    path: "themes/monokai-pro.json".into(),
246                },
247            ),
248        ]
249        .into_iter()
250        .collect(),
251    };
252
253    let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
254    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
255    let node_runtime = FakeNodeRuntime::new();
256
257    let store = cx.new_model(|cx| {
258        ExtensionStore::new(
259            PathBuf::from("/the-extension-dir"),
260            None,
261            fs.clone(),
262            http_client.clone(),
263            None,
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: SchemaVersion::ZERO,
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            None,
384            node_runtime.clone(),
385            language_registry.clone(),
386            theme_registry.clone(),
387            cx,
388        )
389    });
390
391    cx.executor().run_until_parked();
392    store.read_with(cx, |store, _| {
393        assert_eq!(store.extension_index, expected_index);
394        assert_eq!(
395            language_registry.language_names(),
396            ["ERB", "Plain Text", "Ruby"]
397        );
398        assert_eq!(
399            language_registry.grammar_names(),
400            ["embedded_template".into(), "ruby".into()]
401        );
402        assert_eq!(
403            theme_registry.list_names(false),
404            [
405                "Gruvbox",
406                "Monokai Dark",
407                "Monokai Light",
408                "Monokai Pro Dark",
409                "Monokai Pro Light",
410                "One Dark",
411            ]
412        );
413
414        // The on-disk manifest limits the number of FS calls that need to be made
415        // on startup.
416        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
417        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
418    });
419
420    store.update(cx, |store, cx| {
421        store.uninstall_extension("zed-ruby".into(), cx)
422    });
423
424    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
425    expected_index.extensions.remove("zed-ruby");
426    expected_index.languages.remove("Ruby");
427    expected_index.languages.remove("ERB");
428
429    store.read_with(cx, |store, _| {
430        assert_eq!(store.extension_index, expected_index);
431        assert_eq!(language_registry.language_names(), ["Plain Text"]);
432        assert_eq!(language_registry.grammar_names(), []);
433    });
434}
435
436#[gpui::test]
437async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
438    init_test(cx);
439    cx.executor().allow_parking();
440
441    let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
442        .parent()
443        .unwrap()
444        .parent()
445        .unwrap();
446    let cache_dir = root_dir.join("target");
447    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
448
449    let fs = Arc::new(RealFs::default());
450    let extensions_dir = temp_tree(json!({
451        "installed": {},
452        "work": {}
453    }));
454    let project_dir = temp_tree(json!({
455        "test.gleam": ""
456    }));
457
458    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
459    let project_dir = project_dir.path().canonicalize().unwrap();
460
461    let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
462
463    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
464    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
465    let node_runtime = FakeNodeRuntime::new();
466
467    let mut status_updates = language_registry.language_server_binary_statuses();
468
469    struct FakeLanguageServerVersion {
470        version: String,
471        binary_contents: String,
472        http_request_count: usize,
473    }
474
475    let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
476        version: "v1.2.3".into(),
477        binary_contents: "the-binary-contents".into(),
478        http_request_count: 0,
479    }));
480
481    let http_client = FakeHttpClient::create({
482        let language_server_version = language_server_version.clone();
483        move |request| {
484            let language_server_version = language_server_version.clone();
485            async move {
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                    language_server_version.lock().http_request_count += 1;
496                    Ok(Response::new(
497                        json!([
498                            {
499                                "tag_name": version,
500                                "prerelease": false,
501                                "tarball_url": "",
502                                "zipball_url": "",
503                                "assets": [
504                                    {
505                                        "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
506                                        "browser_download_url": asset_download_uri
507                                    }
508                                ]
509                            }
510                        ])
511                        .to_string()
512                        .into(),
513                    ))
514                } else if uri == asset_download_uri {
515                    language_server_version.lock().http_request_count += 1;
516                    let mut bytes = Vec::<u8>::new();
517                    let mut archive = async_tar::Builder::new(&mut bytes);
518                    let mut header = async_tar::Header::new_gnu();
519                    header.set_size(binary_contents.len() as u64);
520                    archive
521                        .append_data(&mut header, "gleam", binary_contents.as_bytes())
522                        .await
523                        .unwrap();
524                    archive.into_inner().await.unwrap();
525                    let mut gzipped_bytes = Vec::new();
526                    let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
527                    encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
528                    Ok(Response::new(gzipped_bytes.into()))
529                } else {
530                    Ok(Response::builder().status(404).body("not found".into())?)
531                }
532            }
533        }
534    });
535
536    let extension_store = cx.new_model(|cx| {
537        ExtensionStore::new(
538            extensions_dir.clone(),
539            Some(cache_dir),
540            fs.clone(),
541            http_client.clone(),
542            None,
543            node_runtime,
544            language_registry.clone(),
545            theme_registry.clone(),
546            cx,
547        )
548    });
549
550    // Ensure that debounces fire.
551    let mut events = cx.events(&extension_store);
552    let executor = cx.executor();
553    let _task = cx.executor().spawn(async move {
554        while let Some(event) = events.next().await {
555            match event {
556                crate::Event::StartedReloading => {
557                    executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
558                }
559                _ => (),
560            }
561        }
562    });
563
564    extension_store.update(cx, |_, cx| {
565        cx.subscribe(&extension_store, |_, _, event, _| {
566            if matches!(event, Event::ExtensionFailedToLoad(_)) {
567                panic!("extension failed to load");
568            }
569        })
570        .detach();
571    });
572
573    extension_store
574        .update(cx, |store, cx| {
575            store.install_dev_extension(gleam_extension_dir.clone(), cx)
576        })
577        .await
578        .unwrap();
579
580    let mut fake_servers = language_registry.fake_language_servers("Gleam");
581
582    let buffer = project
583        .update(cx, |project, cx| {
584            project.open_local_buffer(project_dir.join("test.gleam"), cx)
585        })
586        .await
587        .unwrap();
588
589    let fake_server = fake_servers.next().await.unwrap();
590    let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
591    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
592
593    assert_eq!(fake_server.binary.path, expected_server_path);
594    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
595    assert_eq!(
596        fs.load(&expected_server_path).await.unwrap(),
597        expected_binary_contents
598    );
599    assert_eq!(language_server_version.lock().http_request_count, 2);
600    assert_eq!(
601        [
602            status_updates.next().await.unwrap(),
603            status_updates.next().await.unwrap(),
604            status_updates.next().await.unwrap(),
605        ],
606        [
607            (
608                LanguageServerName("gleam".into()),
609                LanguageServerBinaryStatus::CheckingForUpdate
610            ),
611            (
612                LanguageServerName("gleam".into()),
613                LanguageServerBinaryStatus::Downloading
614            ),
615            (
616                LanguageServerName("gleam".into()),
617                LanguageServerBinaryStatus::None
618            )
619        ]
620    );
621
622    // The extension creates custom labels for completion items.
623    fake_server.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
624        Ok(Some(lsp::CompletionResponse::Array(vec![
625            lsp::CompletionItem {
626                label: "foo".into(),
627                kind: Some(lsp::CompletionItemKind::FUNCTION),
628                detail: Some("fn() -> Result(Nil, Error)".into()),
629                ..Default::default()
630            },
631            lsp::CompletionItem {
632                label: "bar.baz".into(),
633                kind: Some(lsp::CompletionItemKind::FUNCTION),
634                detail: Some("fn(List(a)) -> a".into()),
635                ..Default::default()
636            },
637            lsp::CompletionItem {
638                label: "Quux".into(),
639                kind: Some(lsp::CompletionItemKind::CONSTRUCTOR),
640                detail: Some("fn(String) -> T".into()),
641                ..Default::default()
642            },
643            lsp::CompletionItem {
644                label: "my_string".into(),
645                kind: Some(lsp::CompletionItemKind::CONSTANT),
646                detail: Some("String".into()),
647                ..Default::default()
648            },
649        ])))
650    });
651
652    let completion_labels = project
653        .update(cx, |project, cx| project.completions(&buffer, 0, cx))
654        .await
655        .unwrap()
656        .into_iter()
657        .map(|c| c.label.text)
658        .collect::<Vec<_>>();
659    assert_eq!(
660        completion_labels,
661        [
662            "foo: fn() -> Result(Nil, Error)".to_string(),
663            "bar.baz: fn(List(a)) -> a".to_string(),
664            "Quux: fn(String) -> T".to_string(),
665            "my_string: String".to_string(),
666        ]
667    );
668
669    // Simulate a new version of the language server being released
670    language_server_version.lock().version = "v2.0.0".into();
671    language_server_version.lock().binary_contents = "the-new-binary-contents".into();
672    language_server_version.lock().http_request_count = 0;
673
674    // Start a new instance of the language server.
675    project.update(cx, |project, cx| {
676        project.restart_language_servers_for_buffers([buffer.clone()], cx)
677    });
678
679    // The extension has cached the binary path, and does not attempt
680    // to reinstall it.
681    let fake_server = fake_servers.next().await.unwrap();
682    assert_eq!(fake_server.binary.path, expected_server_path);
683    assert_eq!(
684        fs.load(&expected_server_path).await.unwrap(),
685        expected_binary_contents
686    );
687    assert_eq!(language_server_version.lock().http_request_count, 0);
688
689    // Reload the extension, clearing its cache.
690    // Start a new instance of the language server.
691    extension_store
692        .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
693        .await;
694
695    cx.executor().run_until_parked();
696    project.update(cx, |project, cx| {
697        project.restart_language_servers_for_buffers([buffer.clone()], cx)
698    });
699
700    // The extension re-fetches the latest version of the language server.
701    let fake_server = fake_servers.next().await.unwrap();
702    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
703    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
704    assert_eq!(fake_server.binary.path, new_expected_server_path);
705    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
706    assert_eq!(
707        fs.load(&new_expected_server_path).await.unwrap(),
708        expected_binary_contents
709    );
710
711    // The old language server directory has been cleaned up.
712    assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
713}
714
715fn init_test(cx: &mut TestAppContext) {
716    cx.update(|cx| {
717        let store = SettingsStore::test(cx);
718        cx.set_global(store);
719        theme::init(theme::LoadThemes::JustBase, cx);
720        Project::init_settings(cx);
721        ExtensionSettings::register(cx);
722        language::init(cx);
723    });
724}