1use std::collections::BTreeSet;
2use std::collections::HashMap;
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use ::fs::{CopyOptions, Fs, RealFs, copy_recursive};
9use anyhow::{Context as _, Result, anyhow, bail};
10use clap::Parser;
11use cloud_api_types::ExtensionProvides;
12use extension::extension_builder::{CompileExtensionOptions, ExtensionBuilder};
13use extension::{ExtensionManifest, ExtensionSnippets};
14use language::LanguageConfig;
15use reqwest_client::ReqwestClient;
16use settings_content::SemanticTokenRules;
17use snippet_provider::file_to_snippets;
18use snippet_provider::format::VsSnippetsFile;
19use task::TaskTemplates;
20use tokio::process::Command;
21use tree_sitter::{Language, Query, WasmStore};
22
23#[derive(Parser, Debug)]
24#[command(name = "zed-extension")]
25struct Args {
26 /// The path to the extension directory
27 #[arg(long)]
28 source_dir: PathBuf,
29 /// The output directory to place the packaged extension.
30 #[arg(long)]
31 output_dir: PathBuf,
32 /// The path to a directory where build dependencies are downloaded
33 #[arg(long)]
34 scratch_dir: PathBuf,
35}
36
37#[tokio::main]
38async fn main() -> Result<()> {
39 env_logger::init();
40
41 let args = Args::parse();
42 let fs = Arc::new(RealFs::new(None, gpui_platform::background_executor()));
43 let engine = wasmtime::Engine::default();
44 let mut wasm_store = WasmStore::new(&engine)?;
45
46 let extension_path = args
47 .source_dir
48 .canonicalize()
49 .context("failed to canonicalize source_dir")?;
50 let scratch_dir = args
51 .scratch_dir
52 .canonicalize()
53 .context("failed to canonicalize scratch_dir")?;
54 let output_dir = if args.output_dir.is_relative() {
55 env::current_dir()?.join(&args.output_dir)
56 } else {
57 args.output_dir
58 };
59
60 log::info!("loading extension manifest");
61 let mut manifest = ExtensionManifest::load(fs.clone(), &extension_path).await?;
62
63 log::info!("compiling extension");
64
65 let user_agent = format!(
66 "Zed Extension CLI/{} ({}; {})",
67 env!("CARGO_PKG_VERSION"),
68 std::env::consts::OS,
69 std::env::consts::ARCH
70 );
71 let http_client = Arc::new(ReqwestClient::user_agent(&user_agent)?);
72
73 let builder = ExtensionBuilder::new(http_client, scratch_dir);
74 builder
75 .compile_extension(
76 &extension_path,
77 &mut manifest,
78 CompileExtensionOptions { release: true },
79 fs.clone(),
80 )
81 .await
82 .context("failed to compile extension")?;
83
84 let extension_provides = manifest.provides();
85 validate_extension_features(&extension_provides)?;
86
87 let grammars = test_grammars(&manifest, &extension_path, &mut wasm_store)?;
88 test_languages(&manifest, &extension_path, &grammars)?;
89 test_themes(&manifest, &extension_path, fs.clone()).await?;
90 test_snippets(&manifest, &extension_path, fs.clone()).await?;
91
92 let archive_dir = output_dir.join("archive");
93 fs::remove_dir_all(&archive_dir).ok();
94 copy_extension_resources(&manifest, &extension_path, &archive_dir, fs.clone())
95 .await
96 .context("failed to copy extension resources")?;
97
98 let tar_output = Command::new("tar")
99 .current_dir(&output_dir)
100 .args(["-czvf", "archive.tar.gz", "-C", "archive", "."])
101 .output()
102 .await
103 .context("failed to run tar")?;
104 if !tar_output.status.success() {
105 bail!(
106 "failed to create archive.tar.gz: {}",
107 String::from_utf8_lossy(&tar_output.stderr)
108 );
109 }
110
111 let manifest_json = serde_json::to_string(&cloud_api_types::ExtensionApiManifest {
112 name: manifest.name,
113 version: manifest.version,
114 description: manifest.description,
115 authors: manifest.authors,
116 schema_version: Some(manifest.schema_version.0),
117 repository: manifest
118 .repository
119 .context("missing repository in extension manifest")?,
120 wasm_api_version: manifest.lib.version.map(|version| version.to_string()),
121 provides: extension_provides,
122 })?;
123 fs::remove_dir_all(&archive_dir)?;
124 fs::write(output_dir.join("manifest.json"), manifest_json.as_bytes())?;
125
126 Ok(())
127}
128
129async fn copy_extension_resources(
130 manifest: &ExtensionManifest,
131 extension_path: &Path,
132 output_dir: &Path,
133 fs: Arc<dyn Fs>,
134) -> Result<()> {
135 fs::create_dir_all(output_dir).context("failed to create output dir")?;
136
137 let manifest_toml = toml::to_string(&manifest).context("failed to serialize manifest")?;
138 fs::write(output_dir.join("extension.toml"), &manifest_toml)
139 .context("failed to write extension.toml")?;
140
141 if manifest.lib.kind.is_some() {
142 fs::copy(
143 extension_path.join("extension.wasm"),
144 output_dir.join("extension.wasm"),
145 )
146 .context("failed to copy extension.wasm")?;
147 }
148
149 if !manifest.grammars.is_empty() {
150 let source_grammars_dir = extension_path.join("grammars");
151 let output_grammars_dir = output_dir.join("grammars");
152 fs::create_dir_all(&output_grammars_dir)?;
153 for grammar_name in manifest.grammars.keys() {
154 let mut grammar_filename = PathBuf::from(grammar_name.as_ref());
155 grammar_filename.set_extension("wasm");
156 fs::copy(
157 source_grammars_dir.join(&grammar_filename),
158 output_grammars_dir.join(&grammar_filename),
159 )
160 .with_context(|| format!("failed to copy grammar '{}'", grammar_filename.display()))?;
161 }
162 }
163
164 if !manifest.themes.is_empty() {
165 let output_themes_dir = output_dir.join("themes");
166 fs::create_dir_all(&output_themes_dir)?;
167 for theme_path in &manifest.themes {
168 fs::copy(
169 extension_path.join(theme_path),
170 output_themes_dir.join(theme_path.file_name().context("invalid theme path")?),
171 )
172 .with_context(|| format!("failed to copy theme '{}'", theme_path.display()))?;
173 }
174 }
175
176 if !manifest.icon_themes.is_empty() {
177 let output_icon_themes_dir = output_dir.join("icon_themes");
178 fs::create_dir_all(&output_icon_themes_dir)?;
179 for icon_theme_path in &manifest.icon_themes {
180 fs::copy(
181 extension_path.join(icon_theme_path),
182 output_icon_themes_dir.join(
183 icon_theme_path
184 .file_name()
185 .context("invalid icon theme path")?,
186 ),
187 )
188 .with_context(|| {
189 format!("failed to copy icon theme '{}'", icon_theme_path.display())
190 })?;
191 }
192
193 let output_icons_dir = output_dir.join("icons");
194 fs::create_dir_all(&output_icons_dir)?;
195 copy_recursive(
196 fs.as_ref(),
197 &extension_path.join("icons"),
198 &output_icons_dir,
199 CopyOptions {
200 overwrite: true,
201 ignore_if_exists: false,
202 },
203 )
204 .await
205 .context("failed to copy icons")?;
206 }
207
208 for (_, agent_entry) in &manifest.agent_servers {
209 if let Some(icon_path) = &agent_entry.icon {
210 let source_icon = extension_path.join(icon_path);
211 let dest_icon = output_dir.join(icon_path);
212
213 // Create parent directory if needed
214 if let Some(parent) = dest_icon.parent() {
215 fs::create_dir_all(parent)?;
216 }
217
218 fs::copy(&source_icon, &dest_icon)
219 .with_context(|| format!("failed to copy agent server icon '{}'", icon_path))?;
220 }
221 }
222
223 if !manifest.languages.is_empty() {
224 let output_languages_dir = output_dir.join("languages");
225 fs::create_dir_all(&output_languages_dir)?;
226 for language_path in &manifest.languages {
227 copy_recursive(
228 fs.as_ref(),
229 &extension_path.join(language_path),
230 &output_languages_dir
231 .join(language_path.file_name().context("invalid language path")?),
232 CopyOptions {
233 overwrite: true,
234 ignore_if_exists: false,
235 },
236 )
237 .await
238 .with_context(|| {
239 format!("failed to copy language dir '{}'", language_path.display())
240 })?;
241 }
242 }
243
244 if !manifest.debug_adapters.is_empty() {
245 for (debug_adapter, entry) in &manifest.debug_adapters {
246 let schema_path = entry.schema_path.clone().unwrap_or_else(|| {
247 PathBuf::from("debug_adapter_schemas".to_owned())
248 .join(debug_adapter.as_ref())
249 .with_extension("json")
250 });
251 let parent = schema_path
252 .parent()
253 .with_context(|| format!("invalid empty schema path for {debug_adapter}"))?;
254 fs::create_dir_all(output_dir.join(parent))?;
255 copy_recursive(
256 fs.as_ref(),
257 &extension_path.join(&schema_path),
258 &output_dir.join(&schema_path),
259 CopyOptions {
260 overwrite: true,
261 ignore_if_exists: false,
262 },
263 )
264 .await
265 .with_context(|| {
266 format!(
267 "failed to copy debug adapter schema '{}'",
268 schema_path.display()
269 )
270 })?;
271 }
272 }
273
274 if let Some(snippets) = manifest.snippets.as_ref() {
275 for snippets_path in snippets.paths() {
276 let parent = snippets_path.parent();
277 if let Some(parent) = parent.filter(|p| p.components().next().is_some()) {
278 fs::create_dir_all(output_dir.join(parent))?;
279 }
280 copy_recursive(
281 fs.as_ref(),
282 &extension_path.join(&snippets_path),
283 &output_dir.join(&snippets_path),
284 CopyOptions {
285 overwrite: true,
286 ignore_if_exists: false,
287 },
288 )
289 .await
290 .with_context(|| {
291 format!("failed to copy snippets from '{}'", snippets_path.display())
292 })?;
293 }
294 }
295
296 Ok(())
297}
298
299fn validate_extension_features(provides: &BTreeSet<ExtensionProvides>) -> Result<()> {
300 if provides.is_empty() {
301 bail!("extension does not provide any features");
302 }
303
304 if provides.contains(&ExtensionProvides::Themes) && provides.len() != 1 {
305 bail!("extension must not provide other features along with themes");
306 }
307
308 if provides.contains(&ExtensionProvides::IconThemes) && provides.len() != 1 {
309 bail!("extension must not provide other features along with icon themes");
310 }
311
312 Ok(())
313}
314
315fn test_grammars(
316 manifest: &ExtensionManifest,
317 extension_path: &Path,
318 wasm_store: &mut WasmStore,
319) -> Result<HashMap<String, Language>> {
320 let mut grammars = HashMap::default();
321 let grammars_dir = extension_path.join("grammars");
322
323 for grammar_name in manifest.grammars.keys() {
324 let mut grammar_path = grammars_dir.join(grammar_name.as_ref());
325 grammar_path.set_extension("wasm");
326
327 let wasm = fs::read(&grammar_path)?;
328 let language = wasm_store.load_language(grammar_name, &wasm)?;
329 log::info!("loaded grammar {grammar_name}");
330 grammars.insert(grammar_name.to_string(), language);
331 }
332
333 Ok(grammars)
334}
335
336fn test_languages(
337 manifest: &ExtensionManifest,
338 extension_path: &Path,
339 grammars: &HashMap<String, Language>,
340) -> Result<()> {
341 for relative_language_dir in &manifest.languages {
342 let language_dir = extension_path.join(relative_language_dir);
343 let config_path = language_dir.join(LanguageConfig::FILE_NAME);
344 let config = LanguageConfig::load(&config_path)?;
345 let grammar = if let Some(name) = &config.grammar {
346 Some(
347 grammars
348 .get(name.as_ref())
349 .with_context(|| format!("grammar not found: '{name}'"))?,
350 )
351 } else {
352 None
353 };
354
355 let query_entries = fs::read_dir(&language_dir)?;
356 for entry in query_entries {
357 let entry = entry?;
358 let file_path = entry.path();
359
360 let Some(file_name) = file_path.file_name().and_then(|name| name.to_str()) else {
361 continue;
362 };
363
364 match file_name {
365 LanguageConfig::FILE_NAME => {
366 // Loaded above
367 }
368 SemanticTokenRules::FILE_NAME => {
369 let _token_rules = SemanticTokenRules::load(&file_path)?;
370 }
371 TaskTemplates::FILE_NAME => {
372 let task_file_content = std::fs::read(&file_path).with_context(|| {
373 anyhow!(
374 "Failed to read tasks file at {path}",
375 path = file_path.display()
376 )
377 })?;
378 let _task_templates =
379 serde_json_lenient::from_slice::<TaskTemplates>(&task_file_content)
380 .with_context(|| {
381 anyhow!(
382 "Failed to parse tasks file at {path}",
383 path = file_path.display()
384 )
385 })?;
386 }
387 _ if file_name.ends_with(".scm") => {
388 let grammar = grammar.with_context(|| {
389 format! {
390 "language {} provides query {} but no grammar",
391 config.name,
392 file_path.display()
393 }
394 })?;
395
396 let query_source = fs::read_to_string(&file_path)?;
397 let _query = Query::new(grammar, &query_source)?;
398 }
399 _ => {}
400 }
401 }
402
403 log::info!("loaded language {}", config.name);
404 }
405
406 Ok(())
407}
408
409async fn test_themes(
410 manifest: &ExtensionManifest,
411 extension_path: &Path,
412 fs: Arc<dyn Fs>,
413) -> Result<()> {
414 for relative_theme_path in &manifest.themes {
415 let theme_path = extension_path.join(relative_theme_path);
416 let theme_family =
417 theme_settings::deserialize_user_theme(&fs.load_bytes(&theme_path).await?)?;
418 log::info!("loaded theme family {}", theme_family.name);
419
420 for theme in &theme_family.themes {
421 if theme
422 .style
423 .colors
424 .deprecated_scrollbar_thumb_background
425 .is_some()
426 {
427 bail!(
428 r#"Theme "{theme_name}" is using a deprecated style property: scrollbar_thumb.background. Use `scrollbar.thumb.background` instead."#,
429 theme_name = theme.name
430 )
431 }
432 }
433 }
434
435 Ok(())
436}
437
438async fn test_snippets(
439 manifest: &ExtensionManifest,
440 extension_path: &Path,
441 fs: Arc<dyn Fs>,
442) -> Result<()> {
443 for relative_snippet_path in manifest
444 .snippets
445 .as_ref()
446 .map(ExtensionSnippets::paths)
447 .into_iter()
448 .flatten()
449 {
450 let snippet_path = extension_path.join(relative_snippet_path);
451 let snippets_content = fs.load_bytes(&snippet_path).await?;
452 let snippets_file = serde_json_lenient::from_slice::<VsSnippetsFile>(&snippets_content)
453 .with_context(|| anyhow!("Failed to parse snippet file at {snippet_path:?}"))?;
454 let snippet_errors = file_to_snippets(snippets_file, &snippet_path)
455 .flat_map(Result::err)
456 .collect::<Vec<_>>();
457 let error_count = snippet_errors.len();
458
459 anyhow::ensure!(
460 error_count == 0,
461 "Could not parse {error_count} snippet{suffix} in file {snippet_path:?}:\n\n{snippet_errors}",
462 suffix = if error_count == 1 { "" } else { "s" },
463 snippet_errors = snippet_errors
464 .iter()
465 .map(ToString::to_string)
466 .collect::<Vec<_>>()
467 .join("\n")
468 );
469 }
470
471 Ok(())
472}