From d3e54c97f4e22c77de7057f010c600d180e15ee6 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 1 Sep 2026 19:59:31 +0000 Subject: [PATCH] util: Make fs_embed! compatible with rust-embed's debug-embed feature (#63577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zed's `fs_embed!` macro hand-implements rust-embed's `RustEmbed` trait in its dev arm so debug builds read assets from the checkout at runtime (introduced with corgi support in #63396). That trait returns `rust_embed::Filenames`, an enum with exactly one variant per compilation context: `Dynamic` when rust-embed's `debug-embed` feature is off, `Embedded` when it is on. The dev arm constructed `Filenames::Dynamic` unconditionally. Cargo unifies features across the entire build graph, so a downstream workspace that consumes Zed's crates by git dependency and enables `rust-embed/debug-embed` anywhere (Delta did, for self-contained debug binaries) changes the enum's shape for `util` itself, and `util` stops compiling with `E0599: no variant named Dynamic`. Zed's own CI can never catch this because nothing in Zed's graph enables the feature; it only surfaces in embedding workspaces, where it blocks any dependency bump past #63396. This change makes the combination supported. `util` gains a forwarding `debug-embed` feature, and `__fs_embed_iter` now has two implementations under cfgs that mirror rust-embed's variant availability: the existing boxed-iterator path when the feature is off, and an `Embedded` path when it is on, which scans the checkout once per embed site and leaks the cached name list (the `Embedded` variant requires `'static` names; a per-process list matches the macro's documented contract that dev edits appear on the next launch, and `get` still reads file contents fresh on every call). Consumers must enable the feature through `util/debug-embed` rather than directly on rust-embed — enabling it behind util's back still breaks, which only rust-embed itself could fix; the feature's documentation says so. A new test exercises the dev arm's `iter` and `get` through the macro, and running util's suite with `--features debug-embed` covers the other variant; both configurations pass 133 tests, plus clippy and fmt. Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/util/Cargo.toml | 6 +++ crates/util/src/util.rs | 116 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index bebac886f4..dc0ca93fcd 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -16,6 +16,12 @@ doctest = true [features] test-support = ["rand", "util_macros", "path/test-support"] +# Embed `fs_embed!` assets at compile time even in debug builds. Workspaces +# that want rust-embed's `debug-embed` behavior must enable it through this +# feature rather than on `rust-embed` directly: the feature changes which +# variants of `rust_embed::Filenames` exist, and `fs_embed!`'s dev arm can +# only construct the right one when this crate sees the same feature state. +debug-embed = ["rust-embed/debug-embed"] [dependencies] anyhow.workspace = true diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 36aeb20f2a..0403dbe156 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -684,26 +684,75 @@ pub mod __rust_embed { /// directory that passes the same rust_embed include/exclude globs the /// release derive uses, so the dev and release file sets are identical. Reuses /// rust_embed's own matcher rather than reimplementing glob semantics. -#[cfg(debug_assertions)] +/// +/// `rust_embed::Filenames` has exactly one variant per compilation context: +/// `Dynamic` without the `debug-embed` feature, `Embedded` with it. Cargo +/// unifies features across the whole build graph, so a consumer workspace +/// enabling `debug-embed` changes the enum this crate sees; each variant +/// below compiles only in the context where its `Filenames` variant exists. +#[cfg(all(debug_assertions, not(feature = "debug-embed")))] #[doc(hidden)] pub fn __fs_embed_iter( root_relative: &str, includes: &[&str], excludes: &[&str], ) -> rust_embed::Filenames { - let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { - return rust_embed::Filenames::Dynamic(Box::new(std::iter::empty::< - std::borrow::Cow<'static, str>, - >())); - }; - let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); let names: Vec> = - rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) - .map(|entry| std::borrow::Cow::Owned(entry.rel_path)) + fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(std::borrow::Cow::Owned) .collect(); rust_embed::Filenames::Dynamic(Box::new(names.into_iter())) } +/// The `debug-embed` arm of [`fs_embed!`]'s dev `iter`. The `Embedded` +/// variant requires `'static` names, so the checkout is scanned once per +/// embed site and the resulting name list is leaked and cached. The list +/// staying fixed for the life of the process matches the macro's contract +/// that dev edits show up on the next launch; file contents still come from +/// `get`, which reads the filesystem on every call. +#[cfg(all(debug_assertions, feature = "debug-embed"))] +#[doc(hidden)] +pub fn __fs_embed_iter( + root_relative: &str, + includes: &[&str], + excludes: &[&str], +) -> rust_embed::Filenames { + use std::collections::BTreeMap; + use std::sync::Mutex; + + /// Keyed by root and globs: distinct embed sites may share a root with + /// different filters. + type EmbedSiteKey = (String, Vec, Vec); + static LEAKED_NAMES: Mutex> = + Mutex::new(BTreeMap::new()); + + let key = ( + root_relative.to_string(), + includes.iter().map(|glob| glob.to_string()).collect(), + excludes.iter().map(|glob| glob.to_string()).collect(), + ); + let names = *LEAKED_NAMES.lock().unwrap().entry(key).or_insert_with(|| { + let names: Vec<&'static str> = fs_embed_file_names(root_relative, includes, excludes) + .into_iter() + .map(|name| &*name.leak()) + .collect(); + names.leak() + }); + rust_embed::Filenames::Embedded(names.iter()) +} + +#[cfg(debug_assertions)] +fn fs_embed_file_names(root_relative: &str, includes: &[&str], excludes: &[&str]) -> Vec { + let Some(root) = dev_repo_root().map(|root| root.join(root_relative)) else { + return Vec::new(); + }; + let matcher = rust_embed::utils::PathMatcher::new(includes, excludes); + rust_embed::utils::get_files(root.to_string_lossy().into_owned(), matcher) + .map(|entry| entry.rel_path) + .collect() +} + /// Backs the dev arm of [`fs_embed!`]'s `get`: reads a single file from the /// checkout, returning `None` when the include/exclude globs filter it out so /// `get` matches release's embedded set exactly. @@ -813,7 +862,9 @@ macro_rules! fs_embed { <$name>::get(file_path) } - fn iter() -> $crate::__rust_embed::Filenames { + fn iter( + ) -> impl ::core::iter::Iterator> + 'static + { $crate::__fs_embed_iter( $root_relative, &[$($($include),*)?], @@ -985,6 +1036,51 @@ impl From> for ConnectionResult { mod tests { use super::*; + /// Exercises `fs_embed!`'s dev arm, whose `RustEmbed::iter` implementation + /// must construct whichever `rust_embed::Filenames` variant the current + /// feature state provides. Running util's tests with and without the + /// `debug-embed` feature covers both variants. + #[cfg(debug_assertions)] + #[test] + fn test_fs_embed_dev_arm_iter_and_get() { + let inherent_names: Vec<_> = FsEmbedTestAssets::iter().collect(); + assert!( + inherent_names.iter().any(|name| name == "util.rs"), + "iter should list files matching the include globs, got {inherent_names:?}" + ); + assert!( + !inherent_names.iter().any(|name| name.ends_with(".toml")), + "iter should filter files excluded by the globs, got {inherent_names:?}" + ); + + let trait_names: Vec<_> = + ::iter().collect(); + assert_eq!(inherent_names, trait_names); + + let file = FsEmbedTestAssets::get("util.rs").expect("util.rs should be readable"); + assert!( + std::str::from_utf8(&file.data) + .expect("util.rs should be utf-8") + .contains("fs_embed"), + "get should read the real file contents" + ); + assert!( + FsEmbedTestAssets::get("Cargo.toml").is_none(), + "get should filter files excluded by the globs" + ); + } + + // Dev arm only: the release arm's derive names this crate as + // `::util`, which does not resolve from util's own unit tests. + #[cfg(debug_assertions)] + crate::fs_embed! { + struct FsEmbedTestAssets, + crate_relative = "src", + root_relative = "crates/util/src", + include = ["*.rs"], + exclude = ["test/**/*"], + } + #[test] fn test_parse_os_release() { let os_release =