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}