Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:07:38.061Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

extension_builder.rs

934 lines · 33.1 KB · rust
1use crate::{
2    ExtensionLibraryKind, ExtensionManifest, GrammarManifestEntry, parse_wasm_extension_version,
3};
4use ::fs::Fs;
5use anyhow::{Context as _, Result, bail};
6use futures::{
7    FutureExt, StreamExt,
8    channel::oneshot::{self, Sender},
9    io,
10};
11use heck::ToSnakeCase;
12use http_client::{self, AsyncBody, HttpClient};
13use language::LanguageConfig;
14use path::PathExt;
15use semver::Version;
16use serde::Deserialize;
17use std::{
18    env, fs, mem,
19    num::NonZeroUsize,
20    ops::Not,
21    path::{Path, PathBuf},
22    sync::Arc,
23};
24use util::{ResultExt, command::Stdio};
25use wasm_encoder::{ComponentSectionId, Encode as _, RawSection, Section as _};
26use wasmparser::Parser;
27
28/// Currently, we compile with Rust's `wasm32-wasip2` target, which works with WASI `preview2` and the component model.
29const RUST_TARGET: &str = "wasm32-wasip2";
30
31/// Compiling Tree-sitter parsers from C to WASM requires Clang 17, and a WASM build of libc
32/// and clang's runtime library. The `wasi-sdk` provides these binaries.
33///
34/// Once Clang 17 and its wasm target are available via system package managers, we won't need
35/// to download this.
36const WASI_SDK_URL: &str = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-25/";
37const WASI_SDK_ASSET_NAME: Option<&str> = cfg_select! {
38    all(target_os = "macos", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-macos.tar.gz"),
39    all(target_os = "macos", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-macos.tar.gz"),
40    all(target_os = "linux", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
41    all(target_os = "linux", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
42    all(target_os = "freebsd", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-linux.tar.gz"),
43    all(target_os = "freebsd", target_arch = "aarch64") => Some("wasi-sdk-25.0-arm64-linux.tar.gz"),
44    all(target_os = "windows", target_arch = "x86_64") => Some("wasi-sdk-25.0-x86_64-windows.tar.gz"),
45    _ => None
46};
47
48pub struct ExtensionBuilder {
49    cache_dir: PathBuf,
50    pub http: Arc<dyn HttpClient>,
51}
52
53pub enum CompilationConcurrency {
54    Unbounded,
55    Bounded(NonZeroUsize),
56}
57
58const DEFAULT_COMPILATION_CONCURRENCY: NonZeroUsize = NonZeroUsize::new(3).unwrap();
59
60pub struct CompileExtensionOptions {
61    pub release: bool,
62    pub max_concurrency: CompilationConcurrency,
63}
64
65impl CompileExtensionOptions {
66    pub const fn dev() -> Self {
67        Self {
68            release: false,
69            max_concurrency: CompilationConcurrency::Bounded(DEFAULT_COMPILATION_CONCURRENCY),
70        }
71    }
72}
73
74#[derive(Deserialize)]
75struct CargoToml {
76    package: CargoTomlPackage,
77}
78
79#[derive(Deserialize)]
80struct CargoTomlPackage {
81    name: String,
82}
83
84impl ExtensionBuilder {
85    pub fn new(http_client: Arc<dyn HttpClient>, cache_dir: PathBuf) -> Self {
86        Self {
87            cache_dir,
88            http: http_client,
89        }
90    }
91
92    pub async fn compile_extension(
93        &self,
94        extension_dir: &Path,
95        extension_manifest: &mut ExtensionManifest,
96        options: CompileExtensionOptions,
97        fs: Arc<dyn Fs>,
98    ) -> Result<()> {
99        let start = std::time::Instant::now();
100
101        populate_defaults(extension_manifest, extension_dir, fs.clone()).await?;
102
103        if extension_dir.is_relative() {
104            bail!(
105                "extension dir {} is not an absolute path",
106                extension_dir.display()
107            );
108        }
109
110        fs.create_dir(&self.cache_dir)
111            .await
112            .context("failed to create cache dir")?;
113
114        let (tx, mut rx) = oneshot::channel();
115
116        let clang_path = extension_manifest.grammars.is_empty().not().then(|| {
117            std::iter::repeat_n(
118                async {
119                    self.install_wasi_sdk_if_needed()
120                        .await
121                        .log_err()
122                        .map(Arc::new)
123                }
124                .shared(),
125                extension_manifest.grammars.len(),
126            )
127        });
128
129        let rust_compilation_task =
130            (extension_manifest.lib.kind == Some(ExtensionLibraryKind::Rust)).then(|| {
131                async {
132                    log::info!("compiling Rust extension {}", extension_dir.display());
133                    self.compile_rust_extension(extension_dir, extension_manifest, tx, &options)
134                        .await
135                        .context("failed to compile Rust extension")?;
136
137                    log::info!("compiled Rust extension {}", extension_dir.display());
138                    Ok(())
139                }
140                .boxed()
141            });
142
143        let grammar_compilation_tasks = extension_manifest
144            .grammars
145            .iter()
146            .zip(clang_path.into_iter().flatten())
147            .map(|((grammar_name, grammar_metadata), clang_path_task)| {
148                async move {
149                    let snake_cased_grammar_name = grammar_name.to_snake_case();
150                    if grammar_name.as_ref() != snake_cased_grammar_name.as_str() {
151                        bail!(
152                            "grammar name '{grammar_name}' must be \
153                                written in snake_case: {snake_cased_grammar_name}"
154                        );
155                    }
156
157                    log::info!(
158                        "compiling grammar {grammar_name} for extension {}",
159                        extension_dir.display()
160                    );
161
162                    let clang_path = clang_path_task
163                        .await
164                        .context("Failed to resolve clang path")?;
165
166                    self.compile_grammar(
167                        extension_dir,
168                        grammar_name.as_ref(),
169                        grammar_metadata,
170                        &clang_path,
171                    )
172                    .await
173                    .with_context(|| format!("failed to compile grammar '{grammar_name}'"))?;
174                    log::info!(
175                        "compiled grammar {grammar_name} for extension {}",
176                        extension_dir.display()
177                    );
178
179                    Ok(())
180                }
181                .boxed()
182            });
183
184        let tasks = rust_compilation_task
185            .into_iter()
186            .chain(grammar_compilation_tasks)
187            .collect::<Vec<_>>();
188
189        match options.max_concurrency {
190            CompilationConcurrency::Unbounded => {
191                futures::future::try_join_all(tasks).await?;
192            }
193            CompilationConcurrency::Bounded(max_concurrency) => {
194                let mut stream = futures::stream::iter(tasks).buffered(max_concurrency.get());
195
196                while let Some(result) = stream.next().await {
197                    result?;
198                }
199            }
200        }
201
202        if let Ok(version) = rx.try_recv() {
203            extension_manifest.lib.version = version;
204        }
205
206        log::info!(
207            "finished compiling extension {} in {time:.2}s",
208            extension_dir.display(),
209            time = start.elapsed().as_secs_f64(),
210        );
211        Ok(())
212    }
213
214    async fn compile_rust_extension(
215        &self,
216        extension_dir: &Path,
217        manifest: &ExtensionManifest,
218        wasm_extension_api_version_tx: Sender<Version>,
219        options: &CompileExtensionOptions,
220    ) -> anyhow::Result<()> {
221        self.install_rust_wasm_target_if_needed().await?;
222
223        let cargo_toml_content = fs::read_to_string(extension_dir.join("Cargo.toml"))?;
224        let cargo_toml: CargoToml = toml::from_str(&cargo_toml_content)?;
225
226        log::info!(
227            "compiling Rust crate for extension {}",
228            extension_dir.display()
229        );
230        let output = util::command::new_command("cargo")
231            .args(["build", "--target", RUST_TARGET])
232            .args(options.release.then_some("--release"))
233            .arg("--target-dir")
234            .arg(extension_dir.join("target"))
235            // WASI builds do not work with sccache and just stuck, so disable it.
236            .env("RUSTC_WRAPPER", "")
237            .current_dir(extension_dir)
238            .output()
239            .await
240            .context("failed to run `cargo`")?;
241        if !output.status.success() {
242            bail!(
243                "failed to build extension {}",
244                String::from_utf8_lossy(&output.stderr)
245            );
246        }
247
248        log::info!(
249            "compiled Rust crate for extension {}",
250            extension_dir.display()
251        );
252
253        let mut wasm_path = PathBuf::from(extension_dir);
254        wasm_path.extend([
255            "target",
256            RUST_TARGET,
257            if options.release { "release" } else { "debug" },
258            &cargo_toml
259                .package
260                .name
261                // The wasm32-wasip2 target normalizes `-` in package names to `_` in the resulting `.wasm` file.
262                .replace('-', "_"),
263        ]);
264        wasm_path.set_extension("wasm");
265
266        log::info!(
267            "encoding wasm component for extension {}",
268            extension_dir.display()
269        );
270
271        let component_bytes = fs::read(&wasm_path)
272            .with_context(|| format!("failed to read output module `{}`", wasm_path.display()))?;
273
274        let component_bytes = self
275            .strip_custom_sections(&component_bytes)
276            .context("failed to strip debug sections from wasm component")?;
277
278        let wasm_extension_api_version =
279            parse_wasm_extension_version(&manifest.id, &component_bytes)
280                .context("compiled wasm did not contain a valid zed extension api version")?;
281        wasm_extension_api_version_tx
282            .send(wasm_extension_api_version)
283            .map_err(|_| anyhow::anyhow!("Failed to send API version"))?;
284
285        let extension_file = extension_dir.join("extension.wasm");
286        fs::write(extension_file.clone(), &component_bytes)
287            .context("failed to write extension.wasm")?;
288
289        log::info!(
290            "extension {} written to {}",
291            extension_dir.display(),
292            extension_file.display()
293        );
294
295        Ok(())
296    }
297
298    async fn compile_grammar(
299        &self,
300        extension_dir: &Path,
301        grammar_name: &str,
302        grammar_metadata: &GrammarManifestEntry,
303        clang_path: &Path,
304    ) -> Result<()> {
305        let mut grammar_repo_dir = extension_dir.to_path_buf();
306        grammar_repo_dir.extend(["grammars", grammar_name]);
307
308        let mut grammar_wasm_path = grammar_repo_dir.clone();
309        grammar_wasm_path.set_extension("wasm");
310
311        log::info!("checking out {grammar_name} parser");
312        self.checkout_repo(
313            &grammar_repo_dir,
314            &grammar_metadata.repository,
315            &grammar_metadata.rev,
316        )
317        .await?;
318
319        let base_grammar_path = grammar_metadata
320            .path
321            .as_ref()
322            .map(|path| grammar_repo_dir.join(path))
323            .unwrap_or(grammar_repo_dir);
324
325        let src_path = base_grammar_path.join("src");
326        let parser_path = src_path.join("parser.c");
327        let scanner_path = src_path.join("scanner.c");
328
329        // Skip recompiling if the WASM object is already newer than the source files
330        if file_newer_than_deps(&grammar_wasm_path, &[&parser_path, &scanner_path]).unwrap_or(false)
331        {
332            log::info!(
333                "skipping compilation of {grammar_name} parser because the existing compiled grammar is up to date"
334            );
335        } else {
336            log::info!("compiling {grammar_name} parser");
337            let clang_output = util::command::new_command(&clang_path)
338                .args(["-fPIC", "-shared", "-Os"])
339                .arg(format!("-Wl,--export=tree_sitter_{grammar_name}"))
340                .arg("-o")
341                .arg(&grammar_wasm_path)
342                .arg("-I")
343                .arg(&src_path)
344                .arg(&parser_path)
345                .args(scanner_path.exists().then_some(scanner_path))
346                .output()
347                .await
348                .context("failed to run clang")?;
349
350            if !clang_output.status.success() {
351                bail!(
352                    "failed to compile {} parser with clang: {}",
353                    grammar_name,
354                    String::from_utf8_lossy(&clang_output.stderr),
355                );
356            }
357        }
358
359        Ok(())
360    }
361
362    async fn checkout_repo(&self, directory: &Path, url: &str, rev: &str) -> Result<()> {
363        let git_dir = directory.join(".git");
364
365        if directory.exists() {
366            let remotes_output = util::command::new_command("git")
367                .arg("--git-dir")
368                .arg(&git_dir)
369                .args(["remote", "get-url", "origin"])
370                .env("GIT_CONFIG_GLOBAL", "/dev/null")
371                .output()
372                .await?;
373            let has_remote = remotes_output.status.success()
374                && String::from_utf8_lossy(&remotes_output.stdout).trim() == url;
375            if !has_remote {
376                bail!(
377                    "grammar directory '{}' already exists, but is not a git clone of '{}'",
378                    directory.display(),
379                    url
380                );
381            }
382        } else {
383            fs::create_dir_all(directory).with_context(|| {
384                format!("failed to create grammar directory {}", directory.display(),)
385            })?;
386            let init_output = util::command::new_command("git")
387                .arg("init")
388                .current_dir(directory)
389                .output()
390                .await?;
391            if !init_output.status.success() {
392                bail!(
393                    "failed to run `git init` in directory '{}'",
394                    directory.display()
395                );
396            }
397
398            let remote_add_output = util::command::new_command("git")
399                .arg("--git-dir")
400                .arg(&git_dir)
401                .args(["remote", "add", "origin", url])
402                .output()
403                .await
404                .context("failed to execute `git remote add`")?;
405            if !remote_add_output.status.success() {
406                bail!(
407                    "failed to add remote {url} for git repository {}",
408                    git_dir.display()
409                );
410            }
411        }
412
413        let fetch_output = util::command::new_command("git")
414            .arg("--git-dir")
415            .arg(&git_dir)
416            .args(["fetch", "--depth", "1", "origin", rev])
417            .output()
418            .await
419            .context("failed to execute `git fetch`")?;
420
421        let checkout_output = util::command::new_command("git")
422            .arg("--git-dir")
423            .arg(&git_dir)
424            .args(["checkout", rev])
425            .current_dir(directory)
426            .output()
427            .await
428            .context("failed to execute `git checkout`")?;
429        if !checkout_output.status.success() {
430            if !fetch_output.status.success() {
431                bail!(
432                    "failed to fetch revision {} in directory '{}'",
433                    rev,
434                    directory.display()
435                );
436            }
437            bail!(
438                "failed to checkout revision {} in directory '{}': {}",
439                rev,
440                directory.display(),
441                String::from_utf8_lossy(&checkout_output.stderr)
442            );
443        }
444
445        Ok(())
446    }
447
448    async fn install_rust_wasm_target_if_needed(&self) -> Result<()> {
449        let rustc_output = util::command::new_command("rustc")
450            .args(["--print", "target-libdir", "--target", RUST_TARGET])
451            .output()
452            .await
453            .context("failed to run rustc")?;
454        if !rustc_output.status.success() {
455            bail!(
456                "failed to retrieve the `{RUST_TARGET}` target libdir: {}",
457                String::from_utf8_lossy(&rustc_output.stderr)
458            );
459        }
460
461        let target_libdir = PathBuf::from(String::from_utf8(rustc_output.stdout)?.trim());
462        if target_libdir.exists() {
463            return Ok(());
464        }
465
466        if which::which("rustup").is_err() {
467            bail!(
468                "the `{RUST_TARGET}` target is not installed, and `rustup` is not available to \
469                 install it. Add the target to your Rust toolchain (e.g. `targets = \
470                 [\"{RUST_TARGET}\"]` for Nix rust-overlay/fenix toolchains) or install it via \
471                 your package manager"
472            );
473        }
474
475        let output = util::command::new_command("rustup")
476            .args(["target", "add", RUST_TARGET])
477            .stderr(Stdio::piped())
478            .stdout(Stdio::inherit())
479            .output()
480            .await
481            .context("failed to run `rustup target add`")?;
482        if !output.status.success() {
483            bail!(
484                "failed to install the `{RUST_TARGET}` target: {}",
485                String::from_utf8_lossy(&output.stderr)
486            );
487        }
488
489        Ok(())
490    }
491
492    async fn install_wasi_sdk_if_needed(&self) -> Result<PathBuf> {
493        if let Some(sdk_path) = env::var_os("WASI_SDK_PATH").filter(|path| !path.is_empty()) {
494            let sdk_path = PathBuf::from(sdk_path);
495            let clang_path = wasi_sdk_clang_path(&sdk_path);
496            if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) {
497                log::info!("using wasi-sdk from WASI_SDK_PATH: {sdk_path:?}");
498                return Ok(clang_path);
499            }
500            log::warn!(
501                "WASI_SDK_PATH is set to {sdk_path:?} but clang was not found at {clang_path:?}, falling back to download"
502            );
503        }
504
505        let url = if let Some(asset_name) = WASI_SDK_ASSET_NAME {
506            format!("{WASI_SDK_URL}{asset_name}")
507        } else {
508            bail!("wasi-sdk is not available for platform {}", env::consts::OS);
509        };
510
511        let wasi_sdk_dir = self.cache_dir.join("wasi-sdk");
512        let clang_path = wasi_sdk_clang_path(&wasi_sdk_dir);
513
514        log::info!("downloading wasi-sdk to {}", wasi_sdk_dir.display());
515
516        if fs::metadata(&clang_path).is_ok_and(|metadata| metadata.is_file()) {
517            return Ok(clang_path);
518        }
519
520        let tar_out_dir = self.cache_dir.join("wasi-sdk-temp");
521
522        fs::remove_dir_all(&wasi_sdk_dir).ok();
523        fs::remove_dir_all(&tar_out_dir).ok();
524        fs::create_dir_all(&tar_out_dir).context("failed to create extraction directory")?;
525
526        let mut response = self.http.get(&url, AsyncBody::default(), true).await?;
527
528        // Write the response to a temporary file
529        let tar_gz_path = self.cache_dir.join("wasi-sdk.tar.gz");
530        let tar_gz_file =
531            fs::File::create(&tar_gz_path).context("failed to create temporary tar.gz file")?;
532        let response_body = response.body_mut();
533
534        let mut async_file = io::AllowStdIo::new(tar_gz_file);
535        io::copy(response_body, &mut async_file)
536            .await
537            .context("failed to stream response to file")?;
538        drop(async_file);
539
540        log::info!("un-tarring wasi-sdk to {}", tar_out_dir.display());
541
542        // Shell out to tar to extract the archive
543        let tar_output = util::command::new_command("tar")
544            .arg("-xzf")
545            .arg(&tar_gz_path)
546            .arg("-C")
547            .arg(&tar_out_dir)
548            .output()
549            .await
550            .context("failed to run tar")?;
551
552        if !tar_output.status.success() {
553            bail!(
554                "failed to extract wasi-sdk archive: {}",
555                String::from_utf8_lossy(&tar_output.stderr)
556            );
557        }
558
559        log::info!("finished downloading wasi-sdk");
560
561        // Clean up the temporary tar.gz file
562        fs::remove_file(&tar_gz_path).ok();
563
564        let inner_dir = fs::read_dir(&tar_out_dir)?
565            .next()
566            .context("no content")?
567            .context("failed to read contents of extracted wasi archive directory")?
568            .path();
569        fs::rename(&inner_dir, &wasi_sdk_dir).context("failed to move extracted wasi dir")?;
570        fs::remove_dir_all(&tar_out_dir).ok();
571
572        Ok(clang_path)
573    }
574
575    // This was adapted from:
576    // https://github.com/bytecodealliance/wasm-tools/blob/e8809bb17fcf69aa8c85cd5e6db7cff5cf36b1de/src/bin/wasm-tools/strip.rs
577    fn strip_custom_sections(&self, input: &Vec<u8>) -> Result<Vec<u8>> {
578        use wasmparser::Payload::*;
579
580        let strip_custom_section = |name: &str| {
581            // Default strip everything but:
582            // * the `name` section
583            // * any `component-type` sections
584            // * the `dylink.0` section
585            // * our custom version section
586            name != "name"
587                && !name.starts_with("component-type:")
588                && name != "dylink.0"
589                && name != "zed:api-version"
590        };
591
592        let mut output = Vec::new();
593        let mut stack = Vec::new();
594
595        for payload in Parser::new(0).parse_all(input) {
596            let payload = payload?;
597
598            // Track nesting depth, so that we don't mess with inner producer sections:
599            match payload {
600                Version { encoding, .. } => {
601                    output.extend_from_slice(match encoding {
602                        wasmparser::Encoding::Component => &wasm_encoder::Component::HEADER,
603                        wasmparser::Encoding::Module => &wasm_encoder::Module::HEADER,
604                    });
605                }
606                ModuleSection { .. } | ComponentSection { .. } => {
607                    stack.push(mem::take(&mut output));
608                    continue;
609                }
610                End { .. } => {
611                    let mut parent = match stack.pop() {
612                        Some(c) => c,
613                        None => break,
614                    };
615                    if output.starts_with(&wasm_encoder::Component::HEADER) {
616                        parent.push(ComponentSectionId::Component as u8);
617                        output.encode(&mut parent);
618                    } else {
619                        parent.push(ComponentSectionId::CoreModule as u8);
620                        output.encode(&mut parent);
621                    }
622                    output = parent;
623                }
624                _ => {}
625            }
626
627            if let CustomSection(c) = &payload
628                && strip_custom_section(c.name())
629            {
630                continue;
631            }
632            if let Some((id, range)) = payload.as_section() {
633                RawSection {
634                    id,
635                    data: &input[range],
636                }
637                .append_to(&mut output);
638            }
639        }
640
641        Ok(output)
642    }
643}
644
645async fn populate_defaults(
646    manifest: &mut ExtensionManifest,
647    extension_path: &Path,
648    fs: Arc<dyn Fs>,
649) -> Result<()> {
650    // For legacy extensions on the v0 schema (aka, using `extension.json`), clear out any existing
651    // contents of the computed fields, since we don't care what the existing values are.
652    if manifest.schema_version.is_v0() {
653        manifest.languages.clear();
654        manifest.grammars.clear();
655        manifest.themes.clear();
656    }
657
658    let cargo_toml_path = extension_path.join("Cargo.toml");
659    if cargo_toml_path.exists() {
660        manifest.lib.kind = Some(ExtensionLibraryKind::Rust);
661    }
662
663    let languages_dir = extension_path.join("languages");
664    if fs.is_dir(&languages_dir).await {
665        let mut language_dir_entries = fs
666            .read_dir(&languages_dir)
667            .await
668            .context("failed to list languages dir")?;
669
670        while let Some(language_dir) = language_dir_entries.next().await {
671            let language_dir = language_dir?;
672            let config_path = language_dir.join(LanguageConfig::FILE_NAME);
673            if fs.is_file(config_path.as_path()).await {
674                let relative_language_dir = language_dir
675                    .strip_prefix(extension_path)?
676                    .to_rel_path_buf()?;
677                if !manifest.languages.contains(&relative_language_dir) {
678                    manifest.languages.push(relative_language_dir);
679                }
680            }
681        }
682    }
683
684    let themes_dir = extension_path.join("themes");
685    if fs.is_dir(&themes_dir).await {
686        let mut theme_dir_entries = fs
687            .read_dir(&themes_dir)
688            .await
689            .context("failed to list themes dir")?;
690
691        while let Some(theme_path) = theme_dir_entries.next().await {
692            let theme_path = theme_path?;
693            if theme_path.extension() == Some("json".as_ref()) {
694                let relative_theme_path =
695                    theme_path.strip_prefix(extension_path)?.to_rel_path_buf()?;
696                if !manifest.themes.contains(&relative_theme_path) {
697                    manifest.themes.push(relative_theme_path);
698                }
699            }
700        }
701    }
702
703    let icon_themes_dir = extension_path.join("icon_themes");
704    if fs.is_dir(&icon_themes_dir).await {
705        let mut icon_theme_dir_entries = fs
706            .read_dir(&icon_themes_dir)
707            .await
708            .context("failed to list icon themes dir")?;
709
710        while let Some(icon_theme_path) = icon_theme_dir_entries.next().await {
711            let icon_theme_path = icon_theme_path?;
712            if icon_theme_path.extension() == Some("json".as_ref()) {
713                let relative_icon_theme_path = icon_theme_path
714                    .strip_prefix(extension_path)?
715                    .to_rel_path_buf()?;
716                if !manifest.icon_themes.contains(&relative_icon_theme_path) {
717                    manifest.icon_themes.push(relative_icon_theme_path);
718                }
719            }
720        }
721    };
722    if manifest.snippets.is_none()
723        && let snippets_json_path = extension_path.join("snippets.json")
724        && fs.is_file(&snippets_json_path).await
725    {
726        manifest.snippets = Some("snippets.json".into());
727    }
728
729    // For legacy extensions on the v0 schema (aka, using `extension.json`), we want to populate the grammars in
730    // the manifest using the contents of the `grammars` directory.
731    if manifest.schema_version.is_v0() {
732        let grammars_dir = extension_path.join("grammars");
733        if fs.is_dir(&grammars_dir).await {
734            let mut grammar_dir_entries = fs
735                .read_dir(&grammars_dir)
736                .await
737                .context("failed to list grammars dir")?;
738
739            while let Some(grammar_path) = grammar_dir_entries.next().await {
740                let grammar_path = grammar_path?;
741                if grammar_path.extension() == Some("toml".as_ref()) {
742                    #[derive(Deserialize)]
743                    struct GrammarConfigToml {
744                        pub repository: String,
745                        pub commit: String,
746                        #[serde(default)]
747                        pub path: Option<String>,
748                    }
749
750                    let grammar_config = fs.load(&grammar_path).await?;
751                    let grammar_config: GrammarConfigToml = toml::from_str(&grammar_config)?;
752
753                    let grammar_name = grammar_path
754                        .file_stem()
755                        .and_then(|stem| stem.to_str())
756                        .context("no grammar name")?;
757                    if !manifest.grammars.contains_key(grammar_name) {
758                        manifest.grammars.insert(
759                            grammar_name.into(),
760                            GrammarManifestEntry {
761                                repository: grammar_config.repository,
762                                rev: grammar_config.commit,
763                                path: grammar_config.path,
764                            },
765                        );
766                    }
767                }
768            }
769        }
770    }
771
772    Ok(())
773}
774
775/// Returns `true` if the target exists and its last modified time is greater than that
776/// of each dependency which exists (i.e., dependency paths which do not exist are ignored).
777///
778/// # Errors
779///
780/// Returns `Err` if any of the underlying file I/O operations fail.
781fn file_newer_than_deps(target: &Path, dependencies: &[&Path]) -> Result<bool, std::io::Error> {
782    if !target.try_exists()? {
783        return Ok(false);
784    }
785    let target_modified = target.metadata()?.modified()?;
786    for dependency in dependencies {
787        if !dependency.try_exists()? {
788            continue;
789        }
790        let dep_modified = dependency.metadata()?.modified()?;
791        if target_modified < dep_modified {
792            return Ok(false);
793        }
794    }
795    Ok(true)
796}
797
798fn wasi_sdk_clang_path(wasi_sdk_dir: &Path) -> PathBuf {
799    let mut clang_path = wasi_sdk_dir.to_path_buf();
800    clang_path.extend(["bin", &format!("clang{}", env::consts::EXE_SUFFIX)]);
801    clang_path
802}
803
804#[cfg(test)]
805mod tests {
806    use std::{
807        path::{Path, PathBuf},
808        str::FromStr,
809        thread::sleep,
810        time::Duration,
811    };
812
813    use gpui::TestAppContext;
814    use indoc::indoc;
815
816    use crate::{
817        ExtensionManifest, ExtensionSnippets,
818        extension_builder::{file_newer_than_deps, populate_defaults},
819    };
820
821    #[test]
822    fn test_file_newer_than_deps() {
823        // Don't use TempTree because we need to guarantee the order
824        let tmpdir = tempfile::tempdir().unwrap();
825        let target = tmpdir.path().join("target.wasm");
826        let dep1 = tmpdir.path().join("parser.c");
827        let dep2 = tmpdir.path().join("scanner.c");
828
829        assert!(
830            !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
831            "target doesn't exist"
832        );
833        std::fs::write(&target, "foo").unwrap(); // Create target
834        assert!(
835            file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
836            "dependencies don't exist; target is newer"
837        );
838        sleep(Duration::from_secs(1));
839        std::fs::write(&dep1, "foo").unwrap(); // Create dep1 (newer than target)
840        // Dependency is newer
841        assert!(
842            !file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
843            "a dependency is newer (target {:?}, dep1 {:?})",
844            target.metadata().unwrap().modified().unwrap(),
845            dep1.metadata().unwrap().modified().unwrap(),
846        );
847        sleep(Duration::from_secs(1));
848        std::fs::write(&dep2, "foo").unwrap(); // Create dep2
849        sleep(Duration::from_secs(1));
850        std::fs::write(&target, "foobar").unwrap(); // Update target
851        assert!(
852            file_newer_than_deps(&target, &[&dep1, &dep2]).unwrap(),
853            "target is newer than dependencies (target {:?}, dep2 {:?})",
854            target.metadata().unwrap().modified().unwrap(),
855            dep2.metadata().unwrap().modified().unwrap(),
856        );
857    }
858
859    #[gpui::test]
860    async fn test_snippet_location_is_kept(cx: &mut TestAppContext) {
861        let fs = fs::FakeFs::new(cx.executor());
862        let extension_path = Path::new("/extension");
863
864        fs.insert_tree(
865            extension_path,
866            serde_json::json!({
867                "extension.toml": indoc! {r#"
868                    id = "test-manifest"
869                    name = "Test Manifest"
870                    version = "0.0.1"
871                    schema_version = 1
872
873                    snippets = "./snippets/snippets.json"
874                    "#
875                },
876                "snippets.json": "",
877            }),
878        )
879        .await;
880
881        let mut manifest = ExtensionManifest::load(fs.clone(), extension_path)
882            .await
883            .unwrap();
884
885        populate_defaults(&mut manifest, extension_path, fs.clone())
886            .await
887            .unwrap();
888
889        assert_eq!(
890            manifest.snippets,
891            Some(ExtensionSnippets::Single(
892                PathBuf::from_str("./snippets/snippets.json").unwrap()
893            ))
894        )
895    }
896
897    #[gpui::test]
898    async fn test_automatic_snippet_location_is_relative(cx: &mut TestAppContext) {
899        let fs = fs::FakeFs::new(cx.executor());
900        let extension_path = Path::new("/extension");
901
902        fs.insert_tree(
903            extension_path,
904            serde_json::json!({
905                "extension.toml": indoc! {r#"
906                    id = "test-manifest"
907                    name = "Test Manifest"
908                    version = "0.0.1"
909                    schema_version = 1
910
911                    "#
912                },
913                "snippets.json": "",
914            }),
915        )
916        .await;
917
918        let mut manifest = ExtensionManifest::load(fs.clone(), extension_path)
919            .await
920            .unwrap();
921
922        populate_defaults(&mut manifest, extension_path, fs.clone())
923            .await
924            .unwrap();
925
926        assert_eq!(
927            manifest.snippets,
928            Some(ExtensionSnippets::Single(
929                PathBuf::from_str("snippets.json").unwrap()
930            ))
931        )
932    }
933}
934
Served at tenant.openagents/omega Member data and write actions are omitted.