main.rs

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