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 assistant_slash_command::SlashCommandRegistry;
  9use async_compression::futures::bufread::GzipEncoder;
 10use collections::BTreeMap;
 11use fs::{FakeFs, Fs, RealFs};
 12use futures::{io::BufReader, AsyncReadExt, StreamExt};
 13use gpui::{Context, SemanticVersion, TestAppContext};
 14use http::{FakeHttpClient, Response};
 15use language::{LanguageMatcher, LanguageRegistry, LanguageServerBinaryStatus, LanguageServerName};
 16use node_runtime::FakeNodeRuntime;
 17use parking_lot::Mutex;
 18use project::{Project, DEFAULT_COMPLETION_CONTEXT};
 19use serde_json::json;
 20use settings::{Settings as _, SettingsStore};
 21use std::{
 22    ffi::OsString,
 23    path::{Path, PathBuf},
 24    sync::Arc,
 25};
 26use theme::ThemeRegistry;
 27use util::test::temp_tree;
 28
 29#[cfg(test)]
 30#[ctor::ctor]
 31fn init_logger() {
 32    if std::env::var("RUST_LOG").is_ok() {
 33        env_logger::init();
 34    }
 35}
 36
 37#[gpui::test]
 38async fn test_extension_store(cx: &mut TestAppContext) {
 39    init_test(cx);
 40
 41    let fs = FakeFs::new(cx.executor());
 42    let http_client = FakeHttpClient::with_200_response();
 43
 44    fs.insert_tree(
 45        "/the-extension-dir",
 46        json!({
 47            "installed": {
 48                "zed-monokai": {
 49                    "extension.json": r#"{
 50                        "id": "zed-monokai",
 51                        "name": "Zed Monokai",
 52                        "version": "2.0.0",
 53                        "themes": {
 54                            "Monokai Dark": "themes/monokai.json",
 55                            "Monokai Light": "themes/monokai.json",
 56                            "Monokai Pro Dark": "themes/monokai-pro.json",
 57                            "Monokai Pro Light": "themes/monokai-pro.json"
 58                        }
 59                    }"#,
 60                    "themes": {
 61                        "monokai.json": r#"{
 62                            "name": "Monokai",
 63                            "author": "Someone",
 64                            "themes": [
 65                                {
 66                                    "name": "Monokai Dark",
 67                                    "appearance": "dark",
 68                                    "style": {}
 69                                },
 70                                {
 71                                    "name": "Monokai Light",
 72                                    "appearance": "light",
 73                                    "style": {}
 74                                }
 75                            ]
 76                        }"#,
 77                        "monokai-pro.json": r#"{
 78                            "name": "Monokai Pro",
 79                            "author": "Someone",
 80                            "themes": [
 81                                {
 82                                    "name": "Monokai Pro Dark",
 83                                    "appearance": "dark",
 84                                    "style": {}
 85                                },
 86                                {
 87                                    "name": "Monokai Pro Light",
 88                                    "appearance": "light",
 89                                    "style": {}
 90                                }
 91                            ]
 92                        }"#,
 93                    }
 94                },
 95                "zed-ruby": {
 96                    "extension.json": r#"{
 97                        "id": "zed-ruby",
 98                        "name": "Zed Ruby",
 99                        "version": "1.0.0",
100                        "grammars": {
101                            "ruby": "grammars/ruby.wasm",
102                            "embedded_template": "grammars/embedded_template.wasm"
103                        },
104                        "languages": {
105                            "ruby": "languages/ruby",
106                            "erb": "languages/erb"
107                        }
108                    }"#,
109                    "grammars": {
110                        "ruby.wasm": "",
111                        "embedded_template.wasm": "",
112                    },
113                    "languages": {
114                        "ruby": {
115                            "config.toml": r#"
116                                name = "Ruby"
117                                grammar = "ruby"
118                                path_suffixes = ["rb"]
119                            "#,
120                            "highlights.scm": "",
121                        },
122                        "erb": {
123                            "config.toml": r#"
124                                name = "ERB"
125                                grammar = "embedded_template"
126                                path_suffixes = ["erb"]
127                            "#,
128                            "highlights.scm": "",
129                        }
130                    },
131                }
132            }
133        }),
134    )
135    .await;
136
137    let mut expected_index = ExtensionIndex {
138        extensions: [
139            (
140                "zed-ruby".into(),
141                ExtensionIndexEntry {
142                    manifest: Arc::new(ExtensionManifest {
143                        id: "zed-ruby".into(),
144                        name: "Zed Ruby".into(),
145                        version: "1.0.0".into(),
146                        schema_version: SchemaVersion::ZERO,
147                        description: None,
148                        authors: Vec::new(),
149                        repository: None,
150                        themes: Default::default(),
151                        lib: Default::default(),
152                        languages: vec!["languages/erb".into(), "languages/ruby".into()],
153                        grammars: [
154                            ("embedded_template".into(), GrammarManifestEntry::default()),
155                            ("ruby".into(), GrammarManifestEntry::default()),
156                        ]
157                        .into_iter()
158                        .collect(),
159                        language_servers: BTreeMap::default(),
160                        slash_commands: 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                        slash_commands: 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 slash_command_registry = SlashCommandRegistry::new();
257    let node_runtime = FakeNodeRuntime::new();
258
259    let store = cx.new_model(|cx| {
260        ExtensionStore::new(
261            PathBuf::from("/the-extension-dir"),
262            None,
263            fs.clone(),
264            http_client.clone(),
265            None,
266            node_runtime.clone(),
267            language_registry.clone(),
268            theme_registry.clone(),
269            slash_command_registry.clone(),
270            cx,
271        )
272    });
273
274    cx.executor().advance_clock(super::RELOAD_DEBOUNCE_DURATION);
275    store.read_with(cx, |store, _| {
276        let index = &store.extension_index;
277        assert_eq!(index.extensions, expected_index.extensions);
278        assert_eq!(index.languages, expected_index.languages);
279        assert_eq!(index.themes, expected_index.themes);
280
281        assert_eq!(
282            language_registry.language_names(),
283            ["ERB", "Plain Text", "Ruby"]
284        );
285        assert_eq!(
286            theme_registry.list_names(false),
287            [
288                "Monokai Dark",
289                "Monokai Light",
290                "Monokai Pro Dark",
291                "Monokai Pro Light",
292                "One Dark",
293            ]
294        );
295    });
296
297    fs.insert_tree(
298        "/the-extension-dir/installed/zed-gruvbox",
299        json!({
300            "extension.json": r#"{
301                "id": "zed-gruvbox",
302                "name": "Zed Gruvbox",
303                "version": "1.0.0",
304                "themes": {
305                    "Gruvbox": "themes/gruvbox.json"
306                }
307            }"#,
308            "themes": {
309                "gruvbox.json": r#"{
310                    "name": "Gruvbox",
311                    "author": "Someone Else",
312                    "themes": [
313                        {
314                            "name": "Gruvbox",
315                            "appearance": "dark",
316                            "style": {}
317                        }
318                    ]
319                }"#,
320            }
321        }),
322    )
323    .await;
324
325    expected_index.extensions.insert(
326        "zed-gruvbox".into(),
327        ExtensionIndexEntry {
328            manifest: Arc::new(ExtensionManifest {
329                id: "zed-gruvbox".into(),
330                name: "Zed Gruvbox".into(),
331                version: "1.0.0".into(),
332                schema_version: SchemaVersion::ZERO,
333                description: None,
334                authors: vec![],
335                repository: None,
336                themes: vec!["themes/gruvbox.json".into()],
337                lib: Default::default(),
338                languages: Default::default(),
339                grammars: BTreeMap::default(),
340                language_servers: BTreeMap::default(),
341                slash_commands: BTreeMap::default(),
342            }),
343            dev: false,
344        },
345    );
346    expected_index.themes.insert(
347        "Gruvbox".into(),
348        ExtensionIndexThemeEntry {
349            extension: "zed-gruvbox".into(),
350            path: "themes/gruvbox.json".into(),
351        },
352    );
353
354    let _ = store.update(cx, |store, cx| store.reload(None, cx));
355
356    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
357    store.read_with(cx, |store, _| {
358        let index = &store.extension_index;
359        assert_eq!(index.extensions, expected_index.extensions);
360        assert_eq!(index.languages, expected_index.languages);
361        assert_eq!(index.themes, expected_index.themes);
362
363        assert_eq!(
364            theme_registry.list_names(false),
365            [
366                "Gruvbox",
367                "Monokai Dark",
368                "Monokai Light",
369                "Monokai Pro Dark",
370                "Monokai Pro Light",
371                "One Dark",
372            ]
373        );
374    });
375
376    let prev_fs_metadata_call_count = fs.metadata_call_count();
377    let prev_fs_read_dir_call_count = fs.read_dir_call_count();
378
379    // Create new extension store, as if Zed were restarting.
380    drop(store);
381    let store = cx.new_model(|cx| {
382        ExtensionStore::new(
383            PathBuf::from("/the-extension-dir"),
384            None,
385            fs.clone(),
386            http_client.clone(),
387            None,
388            node_runtime.clone(),
389            language_registry.clone(),
390            theme_registry.clone(),
391            slash_command_registry,
392            cx,
393        )
394    });
395
396    cx.executor().run_until_parked();
397    store.read_with(cx, |store, _| {
398        assert_eq!(store.extension_index, expected_index);
399        assert_eq!(
400            language_registry.language_names(),
401            ["ERB", "Plain Text", "Ruby"]
402        );
403        assert_eq!(
404            language_registry.grammar_names(),
405            ["embedded_template".into(), "ruby".into()]
406        );
407        assert_eq!(
408            theme_registry.list_names(false),
409            [
410                "Gruvbox",
411                "Monokai Dark",
412                "Monokai Light",
413                "Monokai Pro Dark",
414                "Monokai Pro Light",
415                "One Dark",
416            ]
417        );
418
419        // The on-disk manifest limits the number of FS calls that need to be made
420        // on startup.
421        assert_eq!(fs.read_dir_call_count(), prev_fs_read_dir_call_count);
422        assert_eq!(fs.metadata_call_count(), prev_fs_metadata_call_count + 2);
423    });
424
425    store.update(cx, |store, cx| {
426        store.uninstall_extension("zed-ruby".into(), cx)
427    });
428
429    cx.executor().advance_clock(RELOAD_DEBOUNCE_DURATION);
430    expected_index.extensions.remove("zed-ruby");
431    expected_index.languages.remove("Ruby");
432    expected_index.languages.remove("ERB");
433
434    store.read_with(cx, |store, _| {
435        assert_eq!(store.extension_index, expected_index);
436        assert_eq!(language_registry.language_names(), ["Plain Text"]);
437        assert_eq!(language_registry.grammar_names(), []);
438    });
439}
440
441#[gpui::test]
442async fn test_extension_store_with_gleam_extension(cx: &mut TestAppContext) {
443    init_test(cx);
444    cx.executor().allow_parking();
445
446    let root_dir = Path::new(env!("CARGO_MANIFEST_DIR"))
447        .parent()
448        .unwrap()
449        .parent()
450        .unwrap();
451    let cache_dir = root_dir.join("target");
452    let gleam_extension_dir = root_dir.join("extensions").join("gleam");
453
454    let fs = Arc::new(RealFs::default());
455    let extensions_dir = temp_tree(json!({
456        "installed": {},
457        "work": {}
458    }));
459    let project_dir = temp_tree(json!({
460        "test.gleam": ""
461    }));
462
463    let extensions_dir = extensions_dir.path().canonicalize().unwrap();
464    let project_dir = project_dir.path().canonicalize().unwrap();
465
466    let project = Project::test(fs.clone(), [project_dir.as_path()], cx).await;
467
468    let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
469    let theme_registry = Arc::new(ThemeRegistry::new(Box::new(())));
470    let slash_command_registry = SlashCommandRegistry::new();
471    let node_runtime = FakeNodeRuntime::new();
472
473    let mut status_updates = language_registry.language_server_binary_statuses();
474
475    struct FakeLanguageServerVersion {
476        version: String,
477        binary_contents: String,
478        http_request_count: usize,
479    }
480
481    let language_server_version = Arc::new(Mutex::new(FakeLanguageServerVersion {
482        version: "v1.2.3".into(),
483        binary_contents: "the-binary-contents".into(),
484        http_request_count: 0,
485    }));
486
487    let http_client = FakeHttpClient::create({
488        let language_server_version = language_server_version.clone();
489        move |request| {
490            let language_server_version = language_server_version.clone();
491            async move {
492                let version = language_server_version.lock().version.clone();
493                let binary_contents = language_server_version.lock().binary_contents.clone();
494
495                let github_releases_uri = "https://api.github.com/repos/gleam-lang/gleam/releases";
496                let asset_download_uri =
497                    format!("https://fake-download.example.com/gleam-{version}");
498
499                let uri = request.uri().to_string();
500                if uri == github_releases_uri {
501                    language_server_version.lock().http_request_count += 1;
502                    Ok(Response::new(
503                        json!([
504                            {
505                                "tag_name": version,
506                                "prerelease": false,
507                                "tarball_url": "",
508                                "zipball_url": "",
509                                "assets": [
510                                    {
511                                        "name": format!("gleam-{version}-aarch64-apple-darwin.tar.gz"),
512                                        "browser_download_url": asset_download_uri
513                                    },
514                                    {
515                                        "name": format!("gleam-{version}-x86_64-unknown-linux-musl.tar.gz"),
516                                        "browser_download_url": asset_download_uri
517                                    },
518                                    {
519                                        "name": format!("gleam-{version}-aarch64-unknown-linux-musl.tar.gz"),
520                                        "browser_download_url": asset_download_uri
521                                    }
522                                ]
523                            }
524                        ])
525                        .to_string()
526                        .into(),
527                    ))
528                } else if uri == asset_download_uri {
529                    language_server_version.lock().http_request_count += 1;
530                    let mut bytes = Vec::<u8>::new();
531                    let mut archive = async_tar::Builder::new(&mut bytes);
532                    let mut header = async_tar::Header::new_gnu();
533                    header.set_size(binary_contents.len() as u64);
534                    archive
535                        .append_data(&mut header, "gleam", binary_contents.as_bytes())
536                        .await
537                        .unwrap();
538                    archive.into_inner().await.unwrap();
539                    let mut gzipped_bytes = Vec::new();
540                    let mut encoder = GzipEncoder::new(BufReader::new(bytes.as_slice()));
541                    encoder.read_to_end(&mut gzipped_bytes).await.unwrap();
542                    Ok(Response::new(gzipped_bytes.into()))
543                } else {
544                    Ok(Response::builder().status(404).body("not found".into())?)
545                }
546            }
547        }
548    });
549
550    let extension_store = cx.new_model(|cx| {
551        ExtensionStore::new(
552            extensions_dir.clone(),
553            Some(cache_dir),
554            fs.clone(),
555            http_client.clone(),
556            None,
557            node_runtime,
558            language_registry.clone(),
559            theme_registry.clone(),
560            slash_command_registry,
561            cx,
562        )
563    });
564
565    // Ensure that debounces fire.
566    let mut events = cx.events(&extension_store);
567    let executor = cx.executor();
568    let _task = cx.executor().spawn(async move {
569        while let Some(event) = events.next().await {
570            match event {
571                crate::Event::StartedReloading => {
572                    executor.advance_clock(RELOAD_DEBOUNCE_DURATION);
573                }
574                _ => (),
575            }
576        }
577    });
578
579    extension_store.update(cx, |_, cx| {
580        cx.subscribe(&extension_store, |_, _, event, _| {
581            if matches!(event, Event::ExtensionFailedToLoad(_)) {
582                panic!("extension failed to load");
583            }
584        })
585        .detach();
586    });
587
588    extension_store
589        .update(cx, |store, cx| {
590            store.install_dev_extension(gleam_extension_dir.clone(), cx)
591        })
592        .await
593        .unwrap();
594
595    let mut fake_servers = language_registry.fake_language_servers("Gleam");
596
597    let buffer = project
598        .update(cx, |project, cx| {
599            project.open_local_buffer(project_dir.join("test.gleam"), cx)
600        })
601        .await
602        .unwrap();
603
604    let fake_server = fake_servers.next().await.unwrap();
605    let expected_server_path = extensions_dir.join("work/gleam/gleam-v1.2.3/gleam");
606    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
607
608    assert_eq!(fake_server.binary.path, expected_server_path);
609    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
610    assert_eq!(
611        fs.load(&expected_server_path).await.unwrap(),
612        expected_binary_contents
613    );
614    assert_eq!(language_server_version.lock().http_request_count, 2);
615    assert_eq!(
616        [
617            status_updates.next().await.unwrap(),
618            status_updates.next().await.unwrap(),
619            status_updates.next().await.unwrap(),
620        ],
621        [
622            (
623                LanguageServerName("gleam".into()),
624                LanguageServerBinaryStatus::CheckingForUpdate
625            ),
626            (
627                LanguageServerName("gleam".into()),
628                LanguageServerBinaryStatus::Downloading
629            ),
630            (
631                LanguageServerName("gleam".into()),
632                LanguageServerBinaryStatus::None
633            )
634        ]
635    );
636
637    // The extension creates custom labels for completion items.
638    fake_server.handle_request::<lsp::request::Completion, _, _>(|_, _| async move {
639        Ok(Some(lsp::CompletionResponse::Array(vec![
640            lsp::CompletionItem {
641                label: "foo".into(),
642                kind: Some(lsp::CompletionItemKind::FUNCTION),
643                detail: Some("fn() -> Result(Nil, Error)".into()),
644                ..Default::default()
645            },
646            lsp::CompletionItem {
647                label: "bar.baz".into(),
648                kind: Some(lsp::CompletionItemKind::FUNCTION),
649                detail: Some("fn(List(a)) -> a".into()),
650                ..Default::default()
651            },
652            lsp::CompletionItem {
653                label: "Quux".into(),
654                kind: Some(lsp::CompletionItemKind::CONSTRUCTOR),
655                detail: Some("fn(String) -> T".into()),
656                ..Default::default()
657            },
658            lsp::CompletionItem {
659                label: "my_string".into(),
660                kind: Some(lsp::CompletionItemKind::CONSTANT),
661                detail: Some("String".into()),
662                ..Default::default()
663            },
664        ])))
665    });
666
667    let completion_labels = project
668        .update(cx, |project, cx| {
669            project.completions(&buffer, 0, DEFAULT_COMPLETION_CONTEXT, cx)
670        })
671        .await
672        .unwrap()
673        .into_iter()
674        .map(|c| c.label.text)
675        .collect::<Vec<_>>();
676    assert_eq!(
677        completion_labels,
678        [
679            "foo: fn() -> Result(Nil, Error)".to_string(),
680            "bar.baz: fn(List(a)) -> a".to_string(),
681            "Quux: fn(String) -> T".to_string(),
682            "my_string: String".to_string(),
683        ]
684    );
685
686    // Simulate a new version of the language server being released
687    language_server_version.lock().version = "v2.0.0".into();
688    language_server_version.lock().binary_contents = "the-new-binary-contents".into();
689    language_server_version.lock().http_request_count = 0;
690
691    // Start a new instance of the language server.
692    project.update(cx, |project, cx| {
693        project.restart_language_servers_for_buffers([buffer.clone()], cx)
694    });
695
696    // The extension has cached the binary path, and does not attempt
697    // to reinstall it.
698    let fake_server = fake_servers.next().await.unwrap();
699    assert_eq!(fake_server.binary.path, expected_server_path);
700    assert_eq!(
701        fs.load(&expected_server_path).await.unwrap(),
702        expected_binary_contents
703    );
704    assert_eq!(language_server_version.lock().http_request_count, 0);
705
706    // Reload the extension, clearing its cache.
707    // Start a new instance of the language server.
708    extension_store
709        .update(cx, |store, cx| store.reload(Some("gleam".into()), cx))
710        .await;
711
712    cx.executor().run_until_parked();
713    project.update(cx, |project, cx| {
714        project.restart_language_servers_for_buffers([buffer.clone()], cx)
715    });
716
717    // The extension re-fetches the latest version of the language server.
718    let fake_server = fake_servers.next().await.unwrap();
719    let new_expected_server_path = extensions_dir.join("work/gleam/gleam-v2.0.0/gleam");
720    let expected_binary_contents = language_server_version.lock().binary_contents.clone();
721    assert_eq!(fake_server.binary.path, new_expected_server_path);
722    assert_eq!(fake_server.binary.arguments, [OsString::from("lsp")]);
723    assert_eq!(
724        fs.load(&new_expected_server_path).await.unwrap(),
725        expected_binary_contents
726    );
727
728    // The old language server directory has been cleaned up.
729    assert!(fs.metadata(&expected_server_path).await.unwrap().is_none());
730}
731
732fn init_test(cx: &mut TestAppContext) {
733    cx.update(|cx| {
734        let store = SettingsStore::test(cx);
735        cx.set_global(store);
736        release_channel::init(SemanticVersion::default(), cx);
737        theme::init(theme::LoadThemes::JustBase, cx);
738        Project::init_settings(cx);
739        ExtensionSettings::register(cx);
740        language::init(cx);
741    });
742}