extension_store_test.rs

  1use crate::{
  2    ExtensionIndex, ExtensionIndexEntry, ExtensionIndexLanguageEntry, ExtensionManifest,
  3    ExtensionStore, GrammarManifestEntry,
  4};
  5use async_compression::futures::bufread::GzipEncoder;
  6use collections::BTreeMap;
  7use fs::{FakeFs, Fs};
  8use futures::{io::BufReader, AsyncReadExt, StreamExt};
  9use gpui::{Context, TestAppContext};
 10use language::{
 11    Language, LanguageConfig, LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus,
 12    LanguageServerName,
 13};
 14use node_runtime::FakeNodeRuntime;
 15use project::Project;
 16use serde_json::json;
 17use settings::SettingsStore;
 18use std::{
 19    ffi::OsString,
 20    path::{Path, PathBuf},
 21    sync::Arc,
 22};
 23use theme::ThemeRegistry;
 24use util::http::{FakeHttpClient, Response};
 25
 26#[gpui::test]
 27async fn test_extension_store(cx: &mut TestAppContext) {
 28    cx.update(|cx| {
 29        let store = SettingsStore::test(cx);
 30        cx.set_global(store);
 31        theme::init(theme::LoadThemes::JustBase, cx);
 32    });
 33
 34    let fs = FakeFs::new(cx.executor());
 35    let http_client = FakeHttpClient::with_200_response();
 36
 37    fs.insert_tree(
 38        "/the-extension-dir",
 39        json!({
 40            "installed": {
 41                "zed-monokai": {
 42                    "extension.json": r#"{
 43                        "id": "zed-monokai",
 44                        "name": "Zed Monokai",
 45                        "version": "2.0.0",
 46                        "themes": {
 47                            "Monokai Dark": "themes/monokai.json",
 48                            "Monokai Light": "themes/monokai.json",
 49                            "Monokai Pro Dark": "themes/monokai-pro.json",
 50                            "Monokai Pro Light": "themes/monokai-pro.json"
 51                        }
 52                    }"#,
 53                    "themes": {
 54                        "monokai.json": r#"{
 55                            "name": "Monokai",
 56                            "author": "Someone",
 57                            "themes": [
 58                                {
 59                                    "name": "Monokai Dark",
 60                                    "appearance": "dark",
 61                                    "style": {}
 62                                },
 63                                {
 64                                    "name": "Monokai Light",
 65                                    "appearance": "light",
 66                                    "style": {}
 67                                }
 68                            ]
 69                        }"#,
 70                        "monokai-pro.json": r#"{
 71                            "name": "Monokai Pro",
 72                            "author": "Someone",
 73                            "themes": [
 74                                {
 75                                    "name": "Monokai Pro Dark",
 76                                    "appearance": "dark",
 77                                    "style": {}
 78                                },
 79                                {
 80                                    "name": "Monokai Pro Light",
 81                                    "appearance": "light",
 82                                    "style": {}
 83                                }
 84                            ]
 85                        }"#,
 86                    }
 87                },
 88                "zed-ruby": {
 89                    "extension.json": r#"{
 90                        "id": "zed-ruby",
 91                        "name": "Zed Ruby",
 92                        "version": "1.0.0",
 93                        "grammars": {
 94                            "ruby": "grammars/ruby.wasm",
 95                            "embedded_template": "grammars/embedded_template.wasm"
 96                        },
 97                        "languages": {
 98                            "ruby": "languages/ruby",
 99                            "erb": "languages/erb"
100                        }
101                    }"#,
102                    "grammars": {
103                        "ruby.wasm": "",
104                        "embedded_template.wasm": "",
105                    },
106                    "languages": {
107                        "ruby": {
108                            "config.toml": r#"
109                                name = "Ruby"
110                                grammar = "ruby"
111                                path_suffixes = ["rb"]
112                            "#,
113                            "highlights.scm": "",
114                        },
115                        "erb": {
116                            "config.toml": r#"
117                                name = "ERB"
118                                grammar = "embedded_template"
119                                path_suffixes = ["erb"]
120                            "#,
121                            "highlights.scm": "",
122                        }
123                    },
124                }
125            }
126        }),
127    )
128    .await;
129
130    let mut expected_index = ExtensionIndex {
131        extensions: [
132            (
133                "zed-ruby".into(),
134                ExtensionManifest {
135                    id: "zed-ruby".into(),
136                    name: "Zed Ruby".into(),
137                    version: "1.0.0".into(),
138                    description: None,
139                    authors: Vec::new(),
140                    repository: None,
141                    themes: Default::default(),
142                    lib: Default::default(),
143                    languages: vec!["languages/erb".into(), "languages/ruby".into()],
144                    grammars: [
145                        ("embedded_template".into(), GrammarManifestEntry::default()),
146                        ("ruby".into(), GrammarManifestEntry::default()),
147                    ]
148                    .into_iter()
149                    .collect(),
150                    language_servers: BTreeMap::default(),
151                }
152                .into(),
153            ),
154            (
155                "zed-monokai".into(),
156                ExtensionManifest {
157                    id: "zed-monokai".into(),
158                    name: "Zed Monokai".into(),
159                    version: "2.0.0".into(),
160                    description: None,
161                    authors: vec![],
162                    repository: None,
163                    themes: vec![
164                        "themes/monokai-pro.json".into(),
165                        "themes/monokai.json".into(),
166                    ],
167                    lib: Default::default(),
168                    languages: Default::default(),
169                    grammars: BTreeMap::default(),
170                    language_servers: BTreeMap::default(),
171                }
172                .into(),
173            ),
174        ]
175        .into_iter()
176        .collect(),
177        languages: [
178            (
179                "ERB".into(),
180                ExtensionIndexLanguageEntry {
181                    extension: "zed-ruby".into(),
182                    path: "languages/erb".into(),
183                    grammar: Some("embedded_template".into()),
184                    matcher: LanguageMatcher {
185                        path_suffixes: vec!["erb".into()],
186                        first_line_pattern: None,
187                    },
188                },
189            ),
190            (
191                "Ruby".into(),
192                ExtensionIndexLanguageEntry {
193                    extension: "zed-ruby".into(),
194                    path: "languages/ruby".into(),
195                    grammar: Some("ruby".into()),
196                    matcher: LanguageMatcher {
197                        path_suffixes: vec!["rb".into()],
198                        first_line_pattern: None,
199                    },
200                },
201            ),
202        ]
203        .into_iter()
204        .collect(),
205        themes: [
206            (
207                "Monokai Dark".into(),
208                ExtensionIndexEntry {
209                    extension: "zed-monokai".into(),
210                    path: "themes/monokai.json".into(),
211                },
212            ),
213            (
214                "Monokai Light".into(),
215                ExtensionIndexEntry {
216                    extension: "zed-monokai".into(),
217                    path: "themes/monokai.json".into(),
218                },
219            ),
220            (
221                "Monokai Pro Dark".into(),
222                ExtensionIndexEntry {
223                    extension: "zed-monokai".into(),
224                    path: "themes/monokai-pro.json".into(),
225                },
226            ),
227            (
228                "Monokai Pro Light".into(),
229                ExtensionIndexEntry {
230                    extension: "zed-monokai".into(),
231                    path: "themes/monokai-pro.json".into(),
232                },
233            ),
234        ]
235        .into_iter()
236        .collect(),
237    };
238
239    let language_registry = Arc::new(LanguageRegistry::test());
240    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
241    let node_runtime = FakeNodeRuntime::new();
242
243    let store = cx.new_model(|cx| {
244        ExtensionStore::new(
245            PathBuf::from("/the-extension-dir"),
246            fs.clone(),
247            http_client.clone(),
248            node_runtime.clone(),
249            language_registry.clone(),
250            theme_registry.clone(),
251            cx,
252        )
253    });
254
255    cx.executor().run_until_parked();
256    store.read_with(cx, |store, _| {
257        let index = &store.extension_index;
258        assert_eq!(index.extensions, expected_index.extensions);
259        assert_eq!(index.languages, expected_index.languages);
260        assert_eq!(index.themes, expected_index.themes);
261
262        assert_eq!(
263            language_registry.language_names(),
264            ["ERB", "Plain Text", "Ruby"]
265        );
266        assert_eq!(
267            theme_registry.list_names(false),
268            [
269                "Monokai Dark",
270                "Monokai Light",
271                "Monokai Pro Dark",
272                "Monokai Pro Light",
273                "One Dark",
274            ]
275        );
276    });
277
278    fs.insert_tree(
279        "/the-extension-dir/installed/zed-gruvbox",
280        json!({
281            "extension.json": r#"{
282                "id": "zed-gruvbox",
283                "name": "Zed Gruvbox",
284                "version": "1.0.0",
285                "themes": {
286                    "Gruvbox": "themes/gruvbox.json"
287                }
288            }"#,
289            "themes": {
290                "gruvbox.json": r#"{
291                    "name": "Gruvbox",
292                    "author": "Someone Else",
293                    "themes": [
294                        {
295                            "name": "Gruvbox",
296                            "appearance": "dark",
297                            "style": {}
298                        }
299                    ]
300                }"#,
301            }
302        }),
303    )
304    .await;
305
306    expected_index.extensions.insert(
307        "zed-gruvbox".into(),
308        ExtensionManifest {
309            id: "zed-gruvbox".into(),
310            name: "Zed Gruvbox".into(),
311            version: "1.0.0".into(),
312            description: None,
313            authors: vec![],
314            repository: None,
315            themes: vec!["themes/gruvbox.json".into()],
316            lib: Default::default(),
317            languages: Default::default(),
318            grammars: BTreeMap::default(),
319            language_servers: BTreeMap::default(),
320        }
321        .into(),
322    );
323    expected_index.themes.insert(
324        "Gruvbox".into(),
325        ExtensionIndexEntry {
326            extension: "zed-gruvbox".into(),
327            path: "themes/gruvbox.json".into(),
328        },
329    );
330
331    store.update(cx, |store, cx| store.reload(cx));
332
333    cx.executor().run_until_parked();
334    store.read_with(cx, |store, _| {
335        let index = &store.extension_index;
336        assert_eq!(index.extensions, expected_index.extensions);
337        assert_eq!(index.languages, expected_index.languages);
338        assert_eq!(index.themes, expected_index.themes);
339
340        assert_eq!(
341            theme_registry.list_names(false),
342            [
343                "Gruvbox",
344                "Monokai Dark",
345                "Monokai Light",
346                "Monokai Pro Dark",
347                "Monokai Pro Light",
348                "One Dark",
349            ]
350        );
351    });
352
353    let prev_fs_metadata_call_count = fs.metadata_call_count();
354    let prev_fs_read_dir_call_count = fs.read_dir_call_count();
355
356    // Create new extension store, as if Zed were restarting.
357    drop(store);
358    let store = cx.new_model(|cx| {
359        ExtensionStore::new(
360            PathBuf::from("/the-extension-dir"),
361            fs.clone(),
362            http_client.clone(),
363            node_runtime.clone(),
364            language_registry.clone(),
365            theme_registry.clone(),
366            cx,
367        )
368    });
369
370    cx.executor().run_until_parked();
371    store.read_with(cx, |store, _| {
372        assert_eq!(store.extension_index, expected_index);
373        assert_eq!(
374            language_registry.language_names(),
375            ["ERB", "Plain Text", "Ruby"]
376        );
377        assert_eq!(
378            language_registry.grammar_names(),
379            ["embedded_template".into(), "ruby".into()]
380        );
381        assert_eq!(
382            theme_registry.list_names(false),
383            [
384                "Gruvbox",
385                "Monokai Dark",
386                "Monokai Light",
387                "Monokai Pro Dark",
388                "Monokai Pro Light",
389                "One Dark",
390            ]
391        );
392
393        // The on-disk manifest limits the number of FS calls that need to be made
394        // on startup.
395        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
396        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
397    });
398
399    store.update(cx, |store, cx| {
400        store.uninstall_extension("zed-ruby".into(), cx)
401    });
402
403    cx.executor().run_until_parked();
404    expected_index.extensions.remove("zed-ruby");
405    expected_index.languages.remove("Ruby");
406    expected_index.languages.remove("ERB");
407
408    store.read_with(cx, |store, _| {
409        assert_eq!(store.extension_index, expected_index);
410        assert_eq!(language_registry.language_names(), ["Plain Text"]);
411        assert_eq!(language_registry.grammar_names(), []);
412    });
413}
414
415#[gpui::test]
416async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
417    init_test(cx);
418
419    let gleam_extension_dir = PathBuf::from_iter([
420        env!("CARGO_MANIFEST_DIR"),
421        "..",
422        "..",
423        "extensions",
424        "gleam",
425    ])
426    .canonicalize()
427    .unwrap();
428
429    compile_extension("zed_gleam", &gleam_extension_dir);
430
431    let fs = FakeFs::new(cx.executor());
432    fs.insert_tree("/the-extension-dir", json!({ "installed": {} }))
433        .await;
434    fs.insert_tree_from_real_fs("/the-extension-dir/installed/gleam", gleam_extension_dir)
435        .await;
436
437    fs.insert_tree(
438        "/the-project-dir",
439        json!({
440            ".tool-versions": "rust 1.73.0",
441            "test.gleam": ""
442        }),
443    )
444    .await;
445
446    let project = Project::test(fs.clone(), ["/the-project-dir".as_ref()], cx).await;
447
448    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
449    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
450    let node_runtime = FakeNodeRuntime::new();
451
452    let mut status_updates = language_registry.language_server_binary_statuses();
453
454    let http_client = FakeHttpClient::create({
455        move |request| async move {
456            match request.uri().to_string().as_str() {
457                "https://api.github.com/repos/gleam-lang/gleam/releases" => Ok(Response::new(
458                    json!([
459                        {
460                            "tag_name": "v1.2.3",
461                            "prerelease": false,
462                            "tarball_url": "",
463                            "zipball_url": "",
464                            "assets": [
465                                {
466                                    "name": "gleam-v1.2.3-aarch64-apple-darwin.tar.gz",
467                                    "browser_download_url": "http://example.com/the-download"
468                                }
469                            ]
470                        }
471                    ])
472                    .to_string()
473                    .into(),
474                )),
475
476                "http://example.com/the-download" => {
477                    let mut bytes = Vec::<u8>::new();
478                    let mut archive = async_tar::Builder::new(&mut bytes);
479                    let mut header = async_tar::Header::new_gnu();
480                    let content = "the-gleam-binary-contents".as_bytes();
481                    header.set_size(content.len() as u64);
482                    archive
483                        .append_data(&mut header, "gleam", content)
484                        .await
485                        .unwrap();
486                    archive.into_inner().await.unwrap();
487
488                    let mut gzipped_bytes = Vec::new();
489                    let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
490                    encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
491
492                    Ok(Response::new(gzipped_bytes.into()))
493                }
494
495                _ => Ok(Response::builder().status(404).body("not found".into())?),
496            }
497        }
498    });
499
500    let _store = cx.new_model(|cx| {
501        ExtensionStore::new(
502            PathBuf::from("/the-extension-dir"),
503            fs.clone(),
504            http_client.clone(),
505            node_runtime,
506            language_registry.clone(),
507            theme_registry.clone(),
508            cx,
509        )
510    });
511
512    cx.executor().run_until_parked();
513
514    let mut fake_servers = language_registry.fake_language_servers("Gleam");
515
516    let buffer = project
517        .update(cx, |project, cx| {
518            project.open_local_buffer("/the-project-dir/test.gleam", cx)
519        })
520        .await
521        .unwrap();
522
523    project.update(cx, |project, cx| {
524        project.set_language_for_buffer(
525            &buffer,
526            Arc::new(Language::new(
527                LanguageConfig {
528                    name: "Gleam".into(),
529                    ..Default::default()
530                },
531                None,
532            )),
533            cx,
534        )
535    });
536
537    let fake_server = fake_servers.next().await.unwrap();
538
539    assert_eq!(
540        fs.load("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam".as_ref())
541            .await
542            .unwrap(),
543        "the-gleam-binary-contents"
544    );
545
546    assert_eq!(
547        fake_server.binary.path,
548        PathBuf::from("/the-extension-dir/work/gleam/gleam-v1.2.3/gleam")
549    );
550    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
551
552    assert_eq!(
553        [
554            status_updates.next().await.unwrap(),
555            status_updates.next().await.unwrap(),
556            status_updates.next().await.unwrap(),
557        ],
558        [
559            (
560                LanguageServerName("gleam".into()),
561                LanguageServerBinaryStatus::CheckingForUpdate
562            ),
563            (
564                LanguageServerName("gleam".into()),
565                LanguageServerBinaryStatus::Downloading
566            ),
567            (
568                LanguageServerName("gleam".into()),
569                LanguageServerBinaryStatus::Downloaded
570            )
571        ]
572    );
573}
574
575fn compile_extension(name: &str, extension_dir_path: &Path) {
576    let output = std::process::Command::new("cargo")
577        .args(["component", "build", "--target-dir"])
578        .arg(extension_dir_path.join("target"))
579        .current_dir(&extension_dir_path)
580        .output()
581        .unwrap();
582
583    assert!(
584        output.status.success(),
585        "failed to build component {}",
586        String::from_utf8_lossy(&output.stderr)
587    );
588
589    let mut wasm_path = PathBuf::from(extension_dir_path);
590    wasm_path.extend(["target", "wasm32-wasi", "debug", name]);
591    wasm_path.set_extension("wasm");
592
593    std::fs::rename(wasm_path, extension_dir_path.join("extension.wasm")).unwrap();
594}
595
596fn init_test(cx: &mut TestAppContext) {
597    cx.update(|cx| {
598        let store = SettingsStore::test(cx);
599        cx.set_global(store);
600        theme::init(theme::LoadThemes::JustBase, cx);
601        Project::init_settings(cx);
602        language::init(cx);
603    });
604}