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