Skip to repository content934 lines · 33.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:07:38.061Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
extension_builder.rs
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