1use std::borrow::Cow;
22use std::collections::BTreeSet;
23use std::path::{Path, PathBuf};
24
25use gpui_kit::assets::IconName;
26use gpui_kit::{AssetSource, SharedString};
27
28#[derive(Default)]
31pub struct AppAssets {
32 files: Vec<(&'static str, &'static [u8])>,
33 extra: Vec<Box<dyn AssetSource>>,
34 all_icons: bool,
35}
36
37impl AppAssets {
38 pub fn new() -> Self {
40 Self::default()
41 }
42
43 pub fn icons(mut self, source: impl AssetSource) -> Self {
45 self.extra.push(Box::new(source));
46 self
47 }
48
49 pub fn file(mut self, path: &'static str, bytes: &'static [u8]) -> Self {
51 self.files.push((path, bytes));
52 self
53 }
54
55 pub fn all_icons(mut self) -> Self {
58 self.all_icons = true;
59 self
60 }
61
62 fn base(&self) -> &dyn AssetSource {
63 if self.all_icons { &gpui_kit::assets::AllAssets } else { &gpui_kit::assets::Assets }
64 }
65}
66
67impl AssetSource for AppAssets {
68 fn load(&self, path: &str) -> gpui_kit::Result<Option<Cow<'static, [u8]>>> {
69 if let Some((_, bytes)) = self.files.iter().find(|(p, _)| *p == path) {
70 return Ok(Some(Cow::Borrowed(bytes)));
71 }
72 for source in &self.extra {
73 if let Some(bytes) = source.load(path)? {
74 return Ok(Some(bytes));
75 }
76 }
77 self.base().load(path)
78 }
79
80 fn list(&self, path: &str) -> gpui_kit::Result<Vec<SharedString>> {
81 let mut paths = self.base().list(path)?;
82 for source in &self.extra {
83 paths.extend(source.list(path)?);
84 }
85 paths.extend(self.files.iter().filter(|(p, _)| p.starts_with(path)).map(|(p, _)| SharedString::from(*p)));
86 paths.sort();
87 paths.dedup();
88 Ok(paths)
89 }
90}
91
92pub fn icon_names_used(roots: &[impl AsRef<Path>]) -> BTreeSet<String> {
98 let texts: Vec<String> = rust_files(roots).iter().filter_map(|p| std::fs::read_to_string(p).ok()).collect();
99 let mut markers: BTreeSet<String> = BTreeSet::from(["IconName".to_string()]);
100 for text in &texts {
101 markers.extend(aliases_in(text));
102 }
103 let known: BTreeSet<String> = IconName::ALL.iter().map(|i| format!("{i:?}")).collect();
104 let mut used = BTreeSet::new();
105 for text in &texts {
106 used.extend(names_after(text, &markers).into_iter().filter(|n| known.contains(n)));
107 }
108 used
109}
110
111pub fn missing_icons(assets: &dyn AssetSource, used: &BTreeSet<String>) -> Vec<String> {
113 let mut missing = Vec::new();
114 for name in used {
115 let Some(icon) = IconName::ALL.iter().find(|i| format!("{i:?}") == *name) else { continue };
116 if assets.load(&icon.path()).ok().flatten().is_none() {
117 missing.push(name.clone());
118 }
119 }
120 missing
121}
122
123pub fn assert_icons_bundled(assets: &dyn AssetSource, roots: &[impl AsRef<Path>]) {
126 let used = icon_names_used(roots);
127 assert!(!used.is_empty(), "no icons found in the sources: are the roots right? {:?}", roots.iter().map(|r| r.as_ref().display().to_string()).collect::<Vec<_>>());
128 let missing = missing_icons(assets, &used);
129 assert!(missing.is_empty(), "icons used but not bundled (add them to the app's icon set): {missing:?}");
130}
131
132fn rust_files(roots: &[impl AsRef<Path>]) -> Vec<PathBuf> {
134 let mut out = Vec::new();
135 let mut stack: Vec<PathBuf> = roots.iter().map(|r| r.as_ref().to_path_buf()).collect();
136 while let Some(path) = stack.pop() {
137 if path.is_dir() {
138 let Ok(entries) = std::fs::read_dir(&path) else { continue };
139 stack.extend(entries.flatten().map(|e| e.path()));
140 } else if path.extension().is_some_and(|x| x == "rs") {
141 out.push(path);
142 }
143 }
144 out.sort();
145 out
146}
147
148fn aliases_in(text: &str) -> Vec<String> {
151 let mut out = Vec::new();
152 for (at, _) in text.match_indices("IconName") {
153 let rest = text[at + "IconName".len()..].trim_start();
154 if let Some(after) = rest.strip_prefix("as") {
155 if after.starts_with(char::is_whitespace) {
156 let name = ident_at(after.trim_start());
157 if !name.is_empty() && name != "_" {
158 out.push(name);
159 }
160 }
161 }
162 }
163 for (at, _) in text.match_indices("type ") {
164 if at > 0 && is_ident_char(text[..at].chars().next_back().unwrap_or(' ')) {
165 continue;
166 }
167 let rest = &text[at + 5..];
168 let name = ident_at(rest.trim_start());
169 let Some(eq) = rest.find('=') else { continue };
170 let Some(end) = rest[eq..].find(';') else { continue };
171 let target = rest[eq + 1..eq + end].trim();
172 if !name.is_empty() && (target == "IconName" || target.ends_with("::IconName")) {
173 out.push(name);
174 }
175 }
176 out
177}
178
179fn names_after(text: &str, markers: &BTreeSet<String>) -> Vec<String> {
181 let mut out = Vec::new();
182 for m in markers {
183 let pat = format!("{m}::");
184 for (at, _) in text.match_indices(&pat) {
185 if text[..at].chars().next_back().is_some_and(is_ident_char) {
186 continue;
187 }
188 let name = ident_at(&text[at + pat.len()..]);
189 if name.starts_with(|c: char| c.is_ascii_uppercase()) {
190 out.push(name);
191 }
192 }
193 }
194 out
195}
196
197fn ident_at(s: &str) -> String {
198 s.chars().take_while(|c| is_ident_char(*c)).collect()
199}
200
201fn is_ident_char(c: char) -> bool {
202 c.is_ascii_alphanumeric() || c == '_'
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208
209 fn markers(text: &str) -> BTreeSet<String> {
210 let mut m = BTreeSet::from(["IconName".to_string()]);
211 m.extend(aliases_in(text));
212 m
213 }
214
215 #[test]
216 fn finds_names_and_aliases() {
217 let text = r#"
218 use gpui_kit::assets::IconName as I;
219 use gpui_kit::{assets::{IconName as Lucide, Assets}, px};
220 type Icons = gpui_kit::assets::IconName;
221 fn f() { (IconName::Plus, I::Server, Lucide::Columns2, Icons::Flame, UI::Nope, I::new()) }
222 "#;
223 let m = markers(text);
224 assert_eq!(m, BTreeSet::from(["IconName", "I", "Lucide", "Icons"].map(String::from)));
225 let mut names = names_after(text, &m);
226 names.sort();
227 assert_eq!(names, ["Columns2", "Flame", "Plus", "Server"]);
229 }
230
231 #[test]
232 fn an_alias_named_like_a_type_is_filtered_by_real_names() {
233 let text = "use gpui_kit::assets::IconName as Assets; let _ = (Assets::AppWindow, Assets::load);";
235 let names = names_after(text, &markers(text));
236 assert_eq!(names, ["AppWindow"]);
237 }
238
239 #[test]
242 fn every_icon_used_is_bundled() {
243 let root = env!("CARGO_MANIFEST_DIR");
244 assert_icons_bundled(&AppAssets::new(), &[format!("{root}/src/widgets"), format!("{root}/src/param_editor.rs")]);
245 }
246
247 #[test]
248 fn a_missing_icon_is_reported() {
249 let used = BTreeSet::from(["Microscope".to_string(), "Plus".to_string()]);
250 assert_eq!(missing_icons(&AppAssets::new(), &used), ["Microscope"]);
251 gpui_kit::assets::icon_assets!(Extra, [Microscope]);
252 assert!(missing_icons(&AppAssets::new().icons(Extra), &used).is_empty());
253 }
254}