1use std::cell::RefCell;
2use std::collections::{BTreeMap, BTreeSet};
3use std::fmt::Write as _;
4use std::fs;
5use std::fs::OpenOptions;
6use std::io::Write as _;
7use std::ops::Range;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::rc::Rc;
11
12use gpui_rhai::{
13 AppManifest, ComponentDefinition, ComponentInstancePath, ComponentMetadata, ComponentRegistry,
14 DirectoryAssetProvider, EmbeddedScriptSource, FontSource, LocaleManager, ModuleId,
15 RUNTIME_API_VERSION, RestrictedModuleResolver, RuntimeEngine, ScriptAsset, ScriptLifecycle,
16 ThemeManager, ThemeSelection, UiRuntimeState, load_component_styles, load_locale_source,
17 load_theme_source, parse_component_header, validate_font_sources,
18};
19use semver::Version;
20use serde::{Deserialize, Serialize};
21use thiserror::Error;
22use toml_edit::{Array, DocumentMut, InlineTable, Item, Value};
23
24pub mod gallery;
25#[cfg(feature = "test-support")]
26#[doc(hidden)]
27pub mod gallery_app;
28#[cfg(not(feature = "test-support"))]
29mod gallery_app;
30pub mod theme_studio;
31
32use gpui_rhai_registry::{
33 AR_LOCALE, BUNDLED_ASSET_SOURCES, BUNDLED_CHART_SOURCES_BY_ID, BUNDLED_COMPONENT_SOURCES_BY_ID,
34 BUNDLED_MOTION_SOURCES_BY_ID, BUNDLED_THEME_SOURCES, DEFAULT_THEME, EN_LOCALE, STUDIO_SOURCE,
35 ZH_CN_LOCALE,
36};
37#[cfg(test)]
38use gpui_rhai_registry::{
39 CALENDAR_SVG, CHECK_SVG, CHEVRON_DOWN_SVG, CHEVRON_LEFT_SVG, CHEVRON_RIGHT_SVG, CHEVRON_UP_SVG,
40 CLOSE_SVG, DATE_NEXT_SVG, DATE_PREVIOUS_SVG, DISCLOSURE_DOWN_SVG, ETHEREAL_THEME, HELP_SVG,
41 INFO_SVG, MINUS_SVG, PLUS_SVG, SEARCH_SVG, SORT_ASCENDING_SVG, SORT_DESCENDING_SVG,
42 WARNING_SVG,
43};
44
45#[derive(Clone, Debug)]
46struct RegistryEntry {
47 metadata: ComponentMetadata,
48 source: &'static str,
49 assets: Vec<RegistryAsset>,
50}
51
52#[derive(Clone, Debug)]
53struct RegistryAsset {
54 path: &'static str,
55 source: &'static str,
56}
57
58const DEFAULT_COMPONENT_STYLES: &str = r"// Application-wide typed component overrides.
59fn component_styles() {
60 #{}
61}
62";
63
64#[derive(Clone, Debug, Default)]
65pub struct BundledRegistry {
66 entries: BTreeMap<ModuleId, RegistryEntry>,
67}
68
69impl BundledRegistry {
70 pub fn load() -> Result<Self, ProjectError> {
77 let mut registry = Self::default();
78 let asset_sources = BUNDLED_ASSET_SOURCES
79 .iter()
80 .copied()
81 .collect::<BTreeMap<_, _>>();
82 for &(catalog_id, source) in BUNDLED_COMPONENT_SOURCES_BY_ID
83 .iter()
84 .chain(BUNDLED_MOTION_SOURCES_BY_ID)
85 .chain(BUNDLED_CHART_SOURCES_BY_ID)
86 {
87 let metadata = parse_component_header(source)?;
88 let id = metadata.id.clone();
89 if id.as_str() != catalog_id {
90 return Err(ProjectError::RegistryIdMismatch {
91 catalog: catalog_id.to_owned(),
92 metadata: id,
93 });
94 }
95 validate_component_documentation(source, &id)?;
96 let declared_assets = metadata
97 .assets
98 .iter()
99 .map(String::as_str)
100 .collect::<BTreeSet<_>>();
101 let assets = metadata
102 .assets
103 .iter()
104 .filter_map(|path| {
105 asset_sources
106 .get_key_value(path.as_str())
107 .map(|(&path, &source)| RegistryAsset { path, source })
108 })
109 .collect::<Vec<_>>();
110 let bundled_assets = assets
111 .iter()
112 .map(|asset| asset.path)
113 .collect::<BTreeSet<_>>();
114 if declared_assets != bundled_assets {
115 return Err(ProjectError::RegistryAssetMismatch {
116 component: id,
117 declared: declared_assets.into_iter().map(ToOwned::to_owned).collect(),
118 bundled: bundled_assets.into_iter().map(ToOwned::to_owned).collect(),
119 });
120 }
121 let id = metadata.id.clone();
122 if registry
123 .entries
124 .insert(
125 id.clone(),
126 RegistryEntry {
127 metadata,
128 source,
129 assets,
130 },
131 )
132 .is_some()
133 {
134 return Err(ProjectError::DuplicateRegistry(id));
135 }
136 }
137 Ok(registry)
138 }
139
140 fn resolve(&self, requested: &[String]) -> Result<Vec<ModuleId>, ProjectError> {
141 let mut ordered = Vec::new();
142 let mut complete = BTreeSet::new();
143 let mut stack = Vec::new();
144 for request in requested {
145 let normalized = if request.contains('/') {
146 request.clone()
147 } else if self
148 .entries
149 .contains_key(&ModuleId::parse(format!("charts/{request}"))?)
150 {
151 format!("charts/{request}")
152 } else {
153 format!("components/{request}")
154 };
155 let id = ModuleId::parse(normalized)?;
156 self.visit(&id, &mut stack, &mut complete, &mut ordered)?;
157 }
158 Ok(ordered)
159 }
160
161 fn visit(
162 &self,
163 id: &ModuleId,
164 stack: &mut Vec<ModuleId>,
165 complete: &mut BTreeSet<ModuleId>,
166 ordered: &mut Vec<ModuleId>,
167 ) -> Result<(), ProjectError> {
168 if complete.contains(id) {
169 return Ok(());
170 }
171 if let Some(start) = stack.iter().position(|active| active == id) {
172 let mut cycle = stack[start..].to_vec();
173 cycle.push(id.clone());
174 return Err(ProjectError::RegistryCycle(cycle));
175 }
176 let entry = self
177 .entries
178 .get(id)
179 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
180 stack.push(id.clone());
181 for dependency in &entry.metadata.dependencies {
182 self.visit(dependency, stack, complete, ordered)?;
183 }
184 stack.pop();
185 complete.insert(id.clone());
186 ordered.push(id.clone());
187 Ok(())
188 }
189}
190
191#[derive(Clone, Debug)]
192pub struct Project {
193 root: PathBuf,
194}
195
196impl Project {
197 #[must_use]
198 pub fn new(root: impl Into<PathBuf>) -> Self {
199 Self { root: root.into() }
200 }
201
202 pub fn plan_init(&self) -> Result<ProjectPlan, ProjectError> {
209 let cargo_path = self.root.join("Cargo.toml");
210 let cargo_source = read(&cargo_path)?;
211 let mut cargo =
212 cargo_source
213 .parse::<DocumentMut>()
214 .map_err(|source| ProjectError::CargoToml {
215 path: cargo_path.clone(),
216 source,
217 })?;
218 if cargo
219 .get("dependencies")
220 .and_then(|dependencies| dependencies.get("gpui-rhai"))
221 .is_none()
222 {
223 let mut features = Array::new();
224 features.push("dev-reload");
225 let mut dependency = InlineTable::new();
226 dependency.insert("version", Value::from("0.1"));
227 dependency.insert("features", Value::Array(features));
228 cargo["dependencies"]["gpui-rhai"] = Item::Value(Value::InlineTable(dependency));
229 }
230
231 let mut plan = ProjectPlan::new(self.root.clone());
232 plan.update(cargo_path, cargo.to_string(), cargo_source);
233 let main_path = self.root.join("src/main.rs");
234 if main_path.exists() {
235 plan.create(self.root.join("gpui-rhai-host-snippet.rs"), host_source())?;
236 } else {
237 plan.create(main_path, host_source())?;
238 }
239 plan.create(self.root.join("ui/main.rhai"), starter_ui())?;
240 plan.create(self.root.join("ui/theme.rhai"), DEFAULT_THEME.to_owned())?;
241 plan.create(
242 self.root.join("ui/styles.rhai"),
243 DEFAULT_COMPONENT_STYLES.to_owned(),
244 )?;
245 for &(name, source) in BUNDLED_THEME_SOURCES
246 .iter()
247 .filter(|(name, _)| *name != "default_dark.rhai")
248 {
249 plan.create(self.root.join("ui/themes").join(name), source.to_owned())?;
250 }
251 plan.create(self.root.join("ui/locales/en.rhai"), EN_LOCALE.to_owned())?;
252 plan.create(
253 self.root.join("ui/locales/zh_cn.rhai"),
254 ZH_CN_LOCALE.to_owned(),
255 )?;
256 plan.create(self.root.join("ui/locales/ar.rhai"), AR_LOCALE.to_owned())?;
257 plan.create(self.root.join("ui/app.toml"), app_manifest_source()?)?;
258 plan.create(
259 self.root.join(".gpui-rhai/manifest.toml"),
260 toml::to_string_pretty(&LocalManifest::default())?,
261 )?;
262 Ok(plan)
263 }
264
265 pub fn plan_add(
272 &self,
273 registry: &BundledRegistry,
274 requested: &[String],
275 ) -> Result<ProjectPlan, ProjectError> {
276 if requested.is_empty() {
277 return Err(ProjectError::NoComponents);
278 }
279 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
280 let manifest_source = read(&manifest_path)?;
281 let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
282 let resolved = registry.resolve(requested)?;
283 let mut plan = ProjectPlan::new(self.root.clone());
284 if resolved.iter().any(|id| id.as_str().starts_with("charts/")) {
285 self.plan_enable_dependency_feature(&mut plan, "charts")?;
286 }
287 for id in resolved {
288 if manifest.components.contains_key(id.as_str()) {
289 continue;
290 }
291 let entry = registry
292 .entries
293 .get(&id)
294 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
295 if !entry.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
296 return Err(ProjectError::IncompatibleRuntime(id));
297 }
298 let relative = component_relative_path(&id)?;
299 plan.create(
300 self.root.join("ui").join(&relative),
301 entry.source.to_owned(),
302 )?;
303 plan.create(
304 self.root.join(".gpui-rhai/baselines").join(&relative),
305 entry.source.to_owned(),
306 )?;
307 for asset in &entry.assets {
308 plan.create(
309 self.root.join("ui/assets").join(asset.path),
310 asset.source.to_owned(),
311 )?;
312 plan.create(
313 self.root
314 .join(".gpui-rhai/baselines/assets")
315 .join(asset.path),
316 asset.source.to_owned(),
317 )?;
318 }
319 let hash = ScriptAsset::new(id.clone(), entry.source.to_owned()).content_hash;
320 manifest.components.insert(
321 id.as_str().to_owned(),
322 InstalledComponent {
323 version: entry.metadata.version.clone(),
324 content_hash: format!("{hash:016x}"),
325 dependencies: entry
326 .metadata
327 .dependencies
328 .iter()
329 .map(ToString::to_string)
330 .collect(),
331 },
332 );
333 }
334 plan.update(
335 manifest_path,
336 toml::to_string_pretty(&manifest)?,
337 manifest_source,
338 );
339 Ok(plan)
340 }
341
342 fn plan_enable_dependency_feature(
343 &self,
344 plan: &mut ProjectPlan,
345 feature: &str,
346 ) -> Result<(), ProjectError> {
347 let path = self.root.join("Cargo.toml");
348 let source = read(&path)?;
349 let mut document =
350 source
351 .parse::<DocumentMut>()
352 .map_err(|source| ProjectError::CargoToml {
353 path: path.clone(),
354 source,
355 })?;
356 let dependency = document
357 .get_mut("dependencies")
358 .and_then(Item::as_table_mut)
359 .and_then(|dependencies| dependencies.get_mut("gpui-rhai"))
360 .ok_or_else(|| ProjectError::MissingRuntimeDependency(path.clone()))?;
361 match dependency {
362 Item::Value(Value::String(version)) => {
363 let version = version.value().to_owned();
364 let mut features = Array::new();
365 features.push(feature);
366 let mut table = InlineTable::new();
367 table.insert("version", Value::from(version));
368 table.insert("features", Value::Array(features));
369 *dependency = Item::Value(Value::InlineTable(table));
370 }
371 Item::Value(Value::InlineTable(table)) => {
372 let features = table
373 .entry("features")
374 .or_insert(Value::Array(Array::new()));
375 let Value::Array(features) = features else {
376 return Err(ProjectError::InvalidRuntimeFeatures(path));
377 };
378 if !features.iter().any(|value| value.as_str() == Some(feature)) {
379 features.push(feature);
380 }
381 }
382 Item::Table(table) => {
383 if !table.contains_key("features") {
384 table.insert("features", Item::Value(Value::Array(Array::new())));
385 }
386 let features = table
387 .get_mut("features")
388 .and_then(Item::as_value_mut)
389 .and_then(Value::as_array_mut)
390 .ok_or_else(|| ProjectError::InvalidRuntimeFeatures(path.clone()))?;
391 if !features.iter().any(|value| value.as_str() == Some(feature)) {
392 features.push(feature);
393 }
394 }
395 _ => return Err(ProjectError::InvalidRuntimeDependency(path)),
396 }
397 plan.update(path, document.to_string(), source);
398 Ok(())
399 }
400
401 pub fn check(&self) -> Result<CheckReport, ProjectError> {
408 validate_gpui_dependency_family(&self.root.join("Cargo.toml"))?;
409 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
410 let manifest: LocalManifest = toml::from_str(&read(&manifest_path)?)?;
411 if manifest.runtime_api != RUNTIME_API_VERSION {
412 return Err(ProjectError::ManifestRuntime {
413 required: manifest.runtime_api,
414 actual: RUNTIME_API_VERSION,
415 });
416 }
417 let mut modules = BTreeMap::new();
418 let mut headers = BTreeMap::new();
419 for (raw_id, installed) in &manifest.components {
420 let id = ModuleId::parse(raw_id.clone())?;
421 let relative = component_relative_path(&id)?;
422 let baseline_source = read(&self.root.join(".gpui-rhai/baselines").join(&relative))?;
423 let baseline_hash = ScriptAsset::new(id.clone(), baseline_source).content_hash;
424 if format!("{baseline_hash:016x}") != installed.content_hash {
425 return Err(ProjectError::BaselineHash(id));
426 }
427 let source = read(&self.root.join("ui").join(relative))?;
428 let header = parse_component_header(&source)?;
429 validate_component_documentation(&source, &id)?;
430 if header.id != id || header.version != installed.version {
431 return Err(ProjectError::InstalledMetadata(id));
432 }
433 headers.insert(id.clone(), header);
434 modules.insert(id, source);
435 }
436
437 let components = validate_component_exports(&modules, &headers)?;
438 validate_entry(&self.root, &modules, &components)?;
439 let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
440 if app.entry.as_str() != "main" {
441 return Err(ProjectError::ManifestEntry(app.entry));
442 }
443 if app.runtime_api != RUNTIME_API_VERSION {
444 return Err(ProjectError::ManifestRuntime {
445 required: app.runtime_api,
446 actual: RUNTIME_API_VERSION,
447 });
448 }
449 app.validate_components(&components)?;
450 let styles_path = self.root.join("ui/styles.rhai");
451 if styles_path.exists() {
452 load_component_styles(
453 RuntimeEngine::new().engine(),
454 "ui/styles.rhai",
455 &read(&styles_path)?,
456 &components,
457 )
458 .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
459 }
460 let theme_source = read(&self.root.join("ui/theme.rhai"))?;
461 let theme_runtime = RuntimeEngine::new();
462 let primary = load_theme_source(theme_runtime.engine(), "ui/theme.rhai", &theme_source)
463 .map_err(|error| ProjectError::Theme(error.to_string()))?;
464 let mut variants = vec![primary.clone()];
465 for path in collect_paths(&self.root.join("ui/themes"))? {
466 let source = read(&path)?;
467 variants.push(
468 load_theme_source(theme_runtime.engine(), &path.to_string_lossy(), &source)
469 .map_err(|error| ProjectError::Theme(error.to_string()))?,
470 );
471 }
472 ThemeManager::from_variants(variants, ThemeSelection::new(primary.family, primary.name))
473 .map_err(|error| ProjectError::Theme(error.to_string()))?;
474 validate_locales(&self.root)?;
475 validate_embedded_assets(&self.root)?;
476 validate_fonts(&self.root)?;
477 Ok(CheckReport {
478 components: manifest.components.len(),
479 entry: app.entry,
480 })
481 }
482
483 pub fn plan_editor_metadata(&self) -> Result<ProjectPlan, ProjectError> {
489 let manifest: LocalManifest =
490 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
491 let mut modules = BTreeMap::new();
492 let mut headers = BTreeMap::new();
493 for raw_id in manifest.components.keys() {
494 let id = ModuleId::parse(raw_id.clone())?;
495 let source = read(&self.root.join("ui").join(component_relative_path(&id)?))?;
496 headers.insert(id.clone(), parse_component_header(&source)?);
497 modules.insert(id, source);
498 }
499 let registry = validate_component_exports(&modules, &headers)?;
500 let metadata = EditorMetadata {
501 runtime_api: RUNTIME_API_VERSION,
502 components: registry
503 .iter()
504 .map(|(_, component)| component.clone())
505 .collect(),
506 };
507 let snippets = editor_snippets(®istry);
508 let definitions = RuntimeEngine::new().definition_source();
509 let mut plan = ProjectPlan::new(self.root.clone());
510 plan.replace_or_create(
511 self.root.join(".gpui-rhai/editor/components.json"),
512 format!("{}\n", serde_json::to_string_pretty(&metadata)?),
513 )?;
514 plan.replace_or_create(
515 self.root.join(".gpui-rhai/editor/snippets.json"),
516 format!("{}\n", serde_json::to_string_pretty(&snippets)?),
517 )?;
518 plan.replace_or_create(
519 self.root
520 .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
521 definitions,
522 )?;
523 Ok(plan)
524 }
525
526 pub fn diff(&self) -> Result<Vec<String>, ProjectError> {
532 let manifest: LocalManifest =
533 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
534 manifest
535 .components
536 .keys()
537 .map(|raw_id| {
538 let id = ModuleId::parse(raw_id.clone())?;
539 let relative = component_relative_path(&id)?;
540 let source = read(&self.root.join("ui").join(&relative))?;
541 let baseline = read(&self.root.join(".gpui-rhai/baselines").join(relative))?;
542 Ok(format!(
543 "{raw_id}: {}",
544 if source == baseline {
545 "unchanged"
546 } else {
547 "modified"
548 }
549 ))
550 })
551 .collect()
552 }
553
554 pub fn plan_update(&self, registry: &BundledRegistry) -> Result<ProjectPlan, ProjectError> {
565 let manifest_path = self.root.join(".gpui-rhai/manifest.toml");
566 let manifest_source = read(&manifest_path)?;
567 let mut manifest: LocalManifest = toml::from_str(&manifest_source)?;
568 let mut plan = ProjectPlan::new(self.root.clone());
569 let requested = manifest.components.keys().cloned().collect::<Vec<_>>();
570 for id in registry.resolve(&requested)? {
571 let upstream = registry
572 .entries
573 .get(&id)
574 .ok_or_else(|| ProjectError::UnknownComponent(id.clone()))?;
575 if !upstream.metadata.runtime_api.contains(RUNTIME_API_VERSION) {
576 return Err(ProjectError::IncompatibleRuntime(id));
577 }
578 let relative = component_relative_path(&id)?;
579 let source_path = self.root.join("ui").join(&relative);
580 let baseline_path = self.root.join(".gpui-rhai/baselines").join(&relative);
581 let installed = manifest.components.get(id.as_str()).cloned();
582 if let Some(installed) = &installed
583 && upstream.metadata.version <= installed.version
584 {
585 continue;
586 }
587 let write_checkpoint = plan.writes.len();
588 if installed.is_some() {
589 let source = read(&source_path)?;
590 let baseline = read(&baseline_path)?;
591 let Ok(merged) = three_way_merge(&baseline, &source, upstream.source) else {
592 let conflict_path = self.root.join(".gpui-rhai/conflicts").join(&relative);
593 plan.replace_or_create(
594 conflict_path,
595 conflict_artifact(&source, &baseline, upstream.source),
596 )?;
597 plan.conflicts.push(id);
598 continue;
599 };
600 plan.update(source_path, merged, source);
601 plan.update(baseline_path, upstream.source.to_owned(), baseline);
602 } else {
603 plan.create(source_path, upstream.source.to_owned())?;
604 plan.create(baseline_path, upstream.source.to_owned())?;
605 }
606 let mut asset_conflict = false;
607 for asset in &upstream.assets {
608 asset_conflict |= self.plan_asset_update(&mut plan, asset)?;
609 }
610 if asset_conflict {
611 let added = plan.writes.split_off(write_checkpoint);
612 let conflicts = self.root.join(".gpui-rhai/conflicts");
613 plan.writes.extend(
614 added
615 .into_iter()
616 .filter(|write| write.path.starts_with(&conflicts)),
617 );
618 plan.conflicts.push(id);
619 continue;
620 }
621 let content_hash = format!(
622 "{:016x}",
623 ScriptAsset::new(id.clone(), upstream.source.to_owned()).content_hash
624 );
625 manifest.components.insert(
626 id.as_str().to_owned(),
627 InstalledComponent {
628 version: upstream.metadata.version.clone(),
629 content_hash,
630 dependencies: upstream
631 .metadata
632 .dependencies
633 .iter()
634 .map(ToString::to_string)
635 .collect(),
636 },
637 );
638 }
639 for &(name, source) in BUNDLED_THEME_SOURCES
640 .iter()
641 .filter(|(name, _)| *name != "default_dark.rhai")
642 {
643 let path = self.root.join("ui/themes").join(name);
644 if !path.exists() {
645 plan.create(path, source.to_owned())?;
646 }
647 }
648 let styles = self.root.join("ui/styles.rhai");
649 if !styles.exists() {
650 plan.create(styles, DEFAULT_COMPONENT_STYLES.to_owned())?;
651 }
652 plan.update(
653 manifest_path,
654 toml::to_string_pretty(&manifest)?,
655 manifest_source,
656 );
657 Ok(plan)
658 }
659
660 fn plan_asset_update(
661 &self,
662 plan: &mut ProjectPlan,
663 asset: &RegistryAsset,
664 ) -> Result<bool, ProjectError> {
665 let source_path = self.root.join("ui/assets").join(asset.path);
666 let baseline_path = self
667 .root
668 .join(".gpui-rhai/baselines/assets")
669 .join(asset.path);
670 match (source_path.exists(), baseline_path.exists()) {
671 (false, false) => {
672 plan.create(source_path, asset.source.to_owned())?;
673 plan.create(baseline_path, asset.source.to_owned())?;
674 }
675 (true, true) => {
676 let source = read(&source_path)?;
677 let baseline = read(&baseline_path)?;
678 let Ok(merged) = three_way_merge(&baseline, &source, asset.source) else {
679 let conflict_path = self
680 .root
681 .join(".gpui-rhai/conflicts/assets")
682 .join(asset.path);
683 plan.replace_or_create(
684 conflict_path,
685 conflict_artifact(&source, &baseline, asset.source),
686 )?;
687 return Ok(true);
688 };
689 plan.update(source_path, merged, source);
690 plan.update(baseline_path, asset.source.to_owned(), baseline);
691 }
692 _ => return Err(ProjectError::IncompleteAssetBaseline(asset.path.to_owned())),
693 }
694 Ok(false)
695 }
696
697 pub fn plan_embed(&self) -> Result<ProjectPlan, ProjectError> {
704 let manifest: LocalManifest =
705 toml::from_str(&read(&self.root.join(".gpui-rhai/manifest.toml"))?)?;
706 let app: AppManifest = toml::from_str(&read(&self.root.join("ui/app.toml"))?)?;
707 if app.entry.as_str() != "main" {
708 return Err(ProjectError::ManifestEntry(app.entry));
709 }
710 let mut modules = vec![(app.entry.to_string(), "../ui/main.rhai".to_owned())];
711 for raw_id in manifest.components.keys() {
712 let id = ModuleId::parse(raw_id.clone())?;
713 let relative = component_relative_path(&id)?;
714 modules.push((id.to_string(), format!("../ui/{}", slash_path(&relative))));
715 }
716 modules.sort();
717
718 let locales = collect_relative_files(&self.root.join("ui/locales"), "../ui/locales")?;
719 let themes = collect_relative_files(&self.root.join("ui/themes"), "../ui/themes")?;
720 let assets = collect_relative_files(&self.root.join("ui/assets"), "../ui/assets")?;
721 let fonts = collect_relative_files(&self.root.join("ui/fonts"), "../ui/fonts")?;
722 let generated = generated_embed_module(&app, &modules, &locales, &themes, &assets, &fonts)?;
723 let path = self.root.join("src/gpui_rhai_embedded.rs");
724 let mut plan = ProjectPlan::new(self.root.clone());
725 match fs::read_to_string(&path) {
726 Ok(existing) => plan.update(path, generated, existing),
727 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
728 plan.create(path, generated)?;
729 }
730 Err(source) => return Err(ProjectError::Io { path, source }),
731 }
732 Ok(plan)
733 }
734
735 pub fn dev(&self) -> Result<(), ProjectError> {
741 let status = Command::new("cargo")
742 .arg("run")
743 .current_dir(&self.root)
744 .status()
745 .map_err(ProjectError::Spawn)?;
746 if status.success() {
747 Ok(())
748 } else {
749 Err(ProjectError::DevFailed(status.code()))
750 }
751 }
752}
753
754fn validate_gpui_dependency_family(path: &Path) -> Result<(), ProjectError> {
755 const GPUI_PRE_VERSION: &str = "=0.3.7";
756 let source = read(path)?;
757 let manifest: toml::Value = toml::from_str(&source)?;
758 let mut tables = Vec::new();
759 for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
760 if let Some(table) = manifest.get(name).and_then(toml::Value::as_table) {
761 tables.push(table);
762 }
763 }
764 if let Some(targets) = manifest.get("target").and_then(toml::Value::as_table) {
765 for target in targets.values().filter_map(toml::Value::as_table) {
766 for name in ["dependencies", "dev-dependencies", "build-dependencies"] {
767 if let Some(table) = target.get(name).and_then(toml::Value::as_table) {
768 tables.push(table);
769 }
770 }
771 }
772 }
773 for table in tables {
774 for (alias, dependency) in table {
775 let (package, version) = match dependency {
776 toml::Value::String(version) => (alias.clone(), Some(version.clone())),
777 toml::Value::Table(spec)
778 if spec.get("workspace").and_then(toml::Value::as_bool) == Some(true) =>
779 {
780 workspace_dependency(path, alias)?.ok_or_else(|| {
781 ProjectError::GpuiDependency(format!(
782 "`{alias}` inherits from workspace dependencies, but no workspace declaration was found"
783 ))
784 })?
785 }
786 toml::Value::Table(spec) => (
787 spec.get("package")
788 .and_then(toml::Value::as_str)
789 .unwrap_or(alias)
790 .to_owned(),
791 spec.get("version")
792 .and_then(toml::Value::as_str)
793 .map(ToOwned::to_owned),
794 ),
795 _ => continue,
796 };
797 let expected = match package.as_str() {
798 "gpui" | "gpui-pre" => Some(("gpui", "gpui-pre")),
799 "gpui-pre-platform" => Some(("gpui_platform", "gpui-pre-platform")),
800 _ => None,
801 };
802 let Some((expected_alias, expected_package)) = expected else {
803 continue;
804 };
805 if alias != expected_alias
806 || package != expected_package
807 || version.as_deref() != Some(GPUI_PRE_VERSION)
808 {
809 return Err(ProjectError::GpuiDependency(format!(
810 "`{alias}` resolves to `{package}` at {version:?}; use `{expected_alias} = {{ package = \"{expected_package}\", version = \"{GPUI_PRE_VERSION}\", default-features = false }}`"
811 )));
812 }
813 }
814 }
815 Ok(())
816}
817
818fn workspace_dependency(
819 member_manifest: &Path,
820 alias: &str,
821) -> Result<Option<(String, Option<String>)>, ProjectError> {
822 let Some(directory) = member_manifest.parent() else {
823 return Ok(None);
824 };
825 for ancestor in directory.ancestors() {
826 let candidate = ancestor.join("Cargo.toml");
827 if !candidate.exists() {
828 continue;
829 }
830 let manifest: toml::Value = toml::from_str(&read(&candidate)?)?;
831 let Some(spec) = manifest
832 .get("workspace")
833 .and_then(|workspace| workspace.get("dependencies"))
834 .and_then(|dependencies| dependencies.get(alias))
835 else {
836 continue;
837 };
838 return Ok(match spec {
839 toml::Value::String(version) => Some((alias.to_owned(), Some(version.clone()))),
840 toml::Value::Table(spec) => Some((
841 spec.get("package")
842 .and_then(toml::Value::as_str)
843 .unwrap_or(alias)
844 .to_owned(),
845 spec.get("version")
846 .and_then(toml::Value::as_str)
847 .map(ToOwned::to_owned),
848 )),
849 _ => None,
850 });
851 }
852 Ok(None)
853}
854
855fn validate_component_documentation(source: &str, id: &ModuleId) -> Result<(), ProjectError> {
856 let lower = source.to_ascii_lowercase();
857 for (label, present) in [
858 ("props", source.contains("// Props:")),
859 (
860 "state",
861 lower.contains("state:") || lower.contains("state/"),
862 ),
863 (
864 "events",
865 lower.contains("event") || lower.contains("emits no events"),
866 ),
867 ("example", source.contains("// Example:")),
868 (
869 "render_component wrapper",
870 source.contains("render_component("),
871 ),
872 ] {
873 if !present {
874 return Err(ProjectError::ComponentDocumentation {
875 component: id.clone(),
876 missing: label,
877 });
878 }
879 }
880 Ok(())
881}
882
883fn validate_locales(root: &Path) -> Result<(), ProjectError> {
884 let engine = RuntimeEngine::new();
885 let mut bundles = Vec::new();
886 for path in collect_paths(&root.join("ui/locales"))? {
887 let source = read(&path)?;
888 bundles.push(
889 load_locale_source(engine.engine(), &path.to_string_lossy(), &source)
890 .map_err(|error| ProjectError::Locale(error.to_string()))?,
891 );
892 }
893 if bundles.is_empty() {
894 return Ok(());
895 }
896 let fallback = bundles
897 .iter()
898 .find(|bundle| bundle.locale == "en")
899 .unwrap_or(&bundles[0])
900 .locale
901 .clone();
902 LocaleManager::new(bundles, fallback.clone(), fallback)
903 .map(|_| ())
904 .map_err(|error| ProjectError::Locale(error.to_string()))
905}
906
907fn validate_embedded_assets(root: &Path) -> Result<(), ProjectError> {
908 let assets = collect_relative_files(&root.join("ui/assets"), "../ui/assets")?;
909 let mut logical = BTreeSet::new();
910 for (name, _) in assets {
911 embedded_asset_mime(&name)?;
912 let name = name
913 .rsplit_once('.')
914 .map_or(name.as_str(), |(stem, _)| stem);
915 if !logical.insert(name.to_owned()) {
916 return Err(ProjectError::DuplicateAssetLogical(name.to_owned()));
917 }
918 }
919 Ok(())
920}
921
922fn validate_fonts(root: &Path) -> Result<(), ProjectError> {
923 let files = collect_relative_files(&root.join("ui/fonts"), "../ui/fonts")?;
924 let mut fonts = Vec::with_capacity(files.len());
925 for (name, _) in files {
926 validate_font_extension(&name)?;
927 let path = root.join("ui/fonts").join(&name);
928 let bytes = fs::read(&path).map_err(|source| ProjectError::Io {
929 path: path.clone(),
930 source,
931 })?;
932 fonts.push(
933 FontSource::new(name, bytes).map_err(|error| ProjectError::Font(error.to_string()))?,
934 );
935 }
936 validate_font_sources(&fonts).map_err(|error| ProjectError::Font(error.to_string()))
937}
938
939#[derive(Clone, Debug, Serialize)]
940struct EditorMetadata {
941 runtime_api: u32,
942 components: Vec<ComponentDefinition>,
943}
944
945#[derive(Clone, Debug, Serialize)]
946struct EditorSnippet {
947 prefix: String,
948 body: Vec<String>,
949 description: String,
950}
951
952fn editor_snippets(registry: &ComponentRegistry) -> BTreeMap<String, EditorSnippet> {
953 registry
954 .iter()
955 .map(|(id, component)| {
956 let alias = id.as_str().rsplit('/').next().unwrap_or("component");
957 let mut body = vec![
958 format!("import \"{id}\" as {alias};"),
959 format!("{alias}::{}(#{{", component.metadata.export),
960 ];
961 let mut placeholder = 1;
962 for (name, field) in &component.schema.props {
963 if field.required {
964 body.push(format!(" {name}: ${{{placeholder}:{name}}},"));
965 placeholder += 1;
966 }
967 }
968 body.push("})".to_owned());
969 (
970 component.metadata.export.clone(),
971 EditorSnippet {
972 prefix: component.metadata.export.clone(),
973 body,
974 description: format!(
975 "{} from {} {}",
976 component.metadata.export, id, component.metadata.version
977 ),
978 },
979 )
980 })
981 .collect()
982}
983
984fn validate_component_exports(
985 modules: &BTreeMap<ModuleId, String>,
986 headers: &BTreeMap<ModuleId, ComponentMetadata>,
987) -> Result<ComponentRegistry, ProjectError> {
988 if modules.is_empty() {
989 return Ok(ComponentRegistry::default());
990 }
991 let source = EmbeddedScriptSource::new(modules.clone());
992 let mut runtime = RuntimeEngine::new();
993 runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
994 let imports = modules
995 .keys()
996 .enumerate()
997 .map(|(index, id)| format!("import \"{id}\" as component_{index};"))
998 .collect::<Vec<_>>()
999 .join("\n");
1000 runtime.compile_self_contained_named("<component-check>", &imports)?;
1001 let exported = runtime.component_exports()?;
1002 for (id, header) in headers {
1003 exported
1004 .get(id)
1005 .ok_or_else(|| ProjectError::MissingExport(id.clone()))?
1006 .validate_header(header)?;
1007 }
1008 Ok(exported)
1009}
1010
1011fn validate_entry(
1012 root: &Path,
1013 modules: &BTreeMap<ModuleId, String>,
1014 components: &ComponentRegistry,
1015) -> Result<(), ProjectError> {
1016 let entry_path = root.join("ui/main.rhai");
1017 let entry = read(&entry_path)?;
1018 let source = EmbeddedScriptSource::new(modules.clone());
1019 let mut runtime = RuntimeEngine::new();
1020 let call_diagnostics = runtime.lint_known_calls("ui/main.rhai", &entry, modules)?;
1021 if !call_diagnostics.is_empty() {
1022 return Err(ProjectError::KnownCalls(
1023 call_diagnostics
1024 .iter()
1025 .map(ToString::to_string)
1026 .collect::<Vec<_>>()
1027 .join("\n"),
1028 ));
1029 }
1030 runtime.set_module_resolver(RestrictedModuleResolver::from_source(&source)?);
1031 let compiled = runtime.compile_self_contained_named("ui/main.rhai", &entry)?;
1032 if !compiled.has_function("view", 1) {
1033 return Err(ProjectError::MissingView);
1034 }
1035 let schema = runtime.root_state_schema(&compiled)?;
1036 let primary_source = read(&root.join("ui/theme.rhai"))?;
1037 let primary = load_theme_source(runtime.engine(), "ui/theme.rhai", &primary_source)
1038 .map_err(|error| ProjectError::Theme(error.to_string()))?;
1039 let mut themes = vec![primary.clone()];
1040 for path in collect_paths(&root.join("ui/themes"))? {
1041 themes.push(
1042 load_theme_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1043 .map_err(|error| ProjectError::Theme(error.to_string()))?,
1044 );
1045 }
1046 let mut state = UiRuntimeState::new();
1047 let styles_path = root.join("ui/styles.rhai");
1048 if styles_path.exists() {
1049 let styles = load_component_styles(
1050 runtime.engine(),
1051 &styles_path.to_string_lossy(),
1052 &read(&styles_path)?,
1053 components,
1054 )
1055 .map_err(|error| ProjectError::ComponentStyle(error.to_string()))?;
1056 state.replace_component_styles_from_host(styles);
1057 }
1058 state.theme = Some(
1059 ThemeManager::from_variants(
1060 themes,
1061 ThemeSelection::new(primary.family.clone(), primary.name.clone()),
1062 )
1063 .map_err(|error| ProjectError::Theme(error.to_string()))?,
1064 );
1065 state.locale = load_check_locales(root)?;
1066 let asset_root = root.join("ui/assets");
1067 if asset_root.exists() {
1068 state
1069 .assets
1070 .register(
1071 "app",
1072 DirectoryAssetProvider::new(&asset_root)
1073 .map_err(|error| ProjectError::Asset(error.to_string()))?,
1074 )
1075 .map_err(|error| ProjectError::Asset(error.to_string()))?;
1076 }
1077 let runtime_state = Rc::new(RefCell::new(state));
1078 let mut lifecycle = ScriptLifecycle::new(
1079 compiled,
1080 runtime_state,
1081 ComponentInstancePath::root("App", "check"),
1082 Some("check".to_owned()),
1083 BTreeMap::new(),
1084 &schema,
1085 )?;
1086 lifecycle.start(&mut runtime)?;
1087 Ok(())
1088}
1089
1090fn load_check_locales(root: &Path) -> Result<Option<LocaleManager>, ProjectError> {
1091 let runtime = RuntimeEngine::new();
1092 let mut bundles = Vec::new();
1093 for path in collect_paths(&root.join("ui/locales"))? {
1094 bundles.push(
1095 load_locale_source(runtime.engine(), &path.to_string_lossy(), &read(&path)?)
1096 .map_err(|error| ProjectError::Locale(error.to_string()))?,
1097 );
1098 }
1099 if bundles.is_empty() {
1100 return Ok(None);
1101 }
1102 let selected = bundles
1103 .iter()
1104 .find(|bundle| bundle.locale == "en")
1105 .unwrap_or(&bundles[0])
1106 .locale
1107 .clone();
1108 LocaleManager::new(bundles, selected.clone(), selected)
1109 .map(Some)
1110 .map_err(|error| ProjectError::Locale(error.to_string()))
1111}
1112
1113#[derive(Clone, Debug, Eq, PartialEq)]
1114struct LineChange {
1115 base: Range<usize>,
1116 replacement: Vec<String>,
1117}
1118
1119fn three_way_merge(base: &str, local: &str, upstream: &str) -> Result<String, ()> {
1120 if local == base {
1121 return Ok(upstream.to_owned());
1122 }
1123 if upstream == base || local == upstream {
1124 return Ok(local.to_owned());
1125 }
1126 let base_lines = split_lines(base);
1127 let local_changes = line_changes(&base_lines, &split_lines(local))?;
1128 let upstream_changes = line_changes(&base_lines, &split_lines(upstream))?;
1129 for local in &local_changes {
1130 for upstream in &upstream_changes {
1131 if changes_overlap(local, upstream) && local != upstream {
1132 return Err(());
1133 }
1134 }
1135 }
1136 let mut changes = local_changes;
1137 for change in upstream_changes {
1138 if !changes.contains(&change) {
1139 changes.push(change);
1140 }
1141 }
1142 changes.sort_by_key(|change| (change.base.start, change.base.end));
1143 let mut output = String::new();
1144 let mut cursor = 0;
1145 for change in changes {
1146 if change.base.start < cursor {
1147 return Err(());
1148 }
1149 output.extend(
1150 base_lines[cursor..change.base.start]
1151 .iter()
1152 .map(String::as_str),
1153 );
1154 output.extend(change.replacement.iter().map(String::as_str));
1155 cursor = change.base.end;
1156 }
1157 output.extend(base_lines[cursor..].iter().map(String::as_str));
1158 Ok(output)
1159}
1160
1161fn split_lines(source: &str) -> Vec<String> {
1162 source
1163 .split_inclusive('\n')
1164 .map(ToOwned::to_owned)
1165 .collect()
1166}
1167
1168fn line_changes(base: &[String], target: &[String]) -> Result<Vec<LineChange>, ()> {
1169 const MAX_LCS_CELLS: usize = 4_000_000;
1170 if base.len().saturating_mul(target.len()) > MAX_LCS_CELLS {
1171 return Err(());
1172 }
1173 let mut lcs = vec![vec![0_u32; target.len().saturating_add(1)]; base.len().saturating_add(1)];
1174 for base_index in (0..base.len()).rev() {
1175 for target_index in (0..target.len()).rev() {
1176 lcs[base_index][target_index] = if base[base_index] == target[target_index] {
1177 lcs[base_index + 1][target_index + 1].saturating_add(1)
1178 } else {
1179 lcs[base_index + 1][target_index].max(lcs[base_index][target_index + 1])
1180 };
1181 }
1182 }
1183 let mut changes = Vec::new();
1184 let mut current: Option<LineChange> = None;
1185 let (mut base_index, mut target_index) = (0, 0);
1186 while base_index < base.len() || target_index < target.len() {
1187 if base_index < base.len()
1188 && target_index < target.len()
1189 && base[base_index] == target[target_index]
1190 {
1191 if let Some(change) = current.take() {
1192 changes.push(change);
1193 }
1194 base_index += 1;
1195 target_index += 1;
1196 } else if target_index < target.len()
1197 && (base_index == base.len()
1198 || lcs[base_index][target_index + 1] > lcs[base_index + 1][target_index])
1199 {
1200 current
1201 .get_or_insert_with(|| LineChange {
1202 base: base_index..base_index,
1203 replacement: Vec::new(),
1204 })
1205 .replacement
1206 .push(target[target_index].clone());
1207 target_index += 1;
1208 } else {
1209 let change = current.get_or_insert_with(|| LineChange {
1210 base: base_index..base_index,
1211 replacement: Vec::new(),
1212 });
1213 base_index += 1;
1214 change.base.end = base_index;
1215 }
1216 }
1217 if let Some(change) = current {
1218 changes.push(change);
1219 }
1220 Ok(changes)
1221}
1222
1223fn changes_overlap(left: &LineChange, right: &LineChange) -> bool {
1224 if left.base.is_empty() && right.base.is_empty() {
1225 left.base.start == right.base.start
1226 } else if left.base.is_empty() {
1227 right.base.start < left.base.start && left.base.start < right.base.end
1228 } else if right.base.is_empty() {
1229 left.base.start < right.base.start && right.base.start < left.base.end
1230 } else {
1231 left.base.start < right.base.end && right.base.start < left.base.end
1232 }
1233}
1234
1235fn conflict_artifact(local: &str, baseline: &str, upstream: &str) -> String {
1236 format!(
1237 "<<<<<<< local\n{}||||||| installed baseline\n{}=======\n{}>>>>>>> bundled registry\n",
1238 with_final_newline(local),
1239 with_final_newline(baseline),
1240 with_final_newline(upstream)
1241 )
1242}
1243
1244fn with_final_newline(source: &str) -> String {
1245 if source.ends_with('\n') {
1246 source.to_owned()
1247 } else {
1248 format!("{source}\n")
1249 }
1250}
1251
1252fn component_relative_path(id: &ModuleId) -> Result<PathBuf, ProjectError> {
1253 let path = id.as_str();
1254 let (directory, component) = path
1255 .strip_prefix("components/")
1256 .map(|name| ("components", name))
1257 .or_else(|| path.strip_prefix("motion/").map(|name| ("motion", name)))
1258 .or_else(|| path.strip_prefix("charts/").map(|name| ("charts", name)))
1259 .ok_or_else(|| ProjectError::InvalidComponentPath(id.clone()))?;
1260 Ok(PathBuf::from(directory)
1261 .join(component)
1262 .with_extension("rhai"))
1263}
1264
1265fn slash_path(path: &Path) -> String {
1266 path.components()
1267 .map(|component| component.as_os_str().to_string_lossy())
1268 .collect::<Vec<_>>()
1269 .join("/")
1270}
1271
1272fn collect_relative_files(
1273 directory: &Path,
1274 include_prefix: &str,
1275) -> Result<Vec<(String, String)>, ProjectError> {
1276 if !directory.exists() {
1277 return Ok(Vec::new());
1278 }
1279 let mut pending = vec![directory.to_path_buf()];
1280 let mut files = Vec::new();
1281 while let Some(current) = pending.pop() {
1282 for entry in fs::read_dir(¤t).map_err(|source| ProjectError::Io {
1283 path: current.clone(),
1284 source,
1285 })? {
1286 let entry = entry.map_err(|source| ProjectError::Io {
1287 path: current.clone(),
1288 source,
1289 })?;
1290 let path = entry.path();
1291 if path.is_dir() {
1292 pending.push(path);
1293 } else {
1294 let relative = path
1295 .strip_prefix(directory)
1296 .map_err(|_| ProjectError::InvalidPath(path.clone()))?;
1297 let logical = slash_path(relative);
1298 files.push((logical.clone(), format!("{include_prefix}/{logical}")));
1299 }
1300 }
1301 }
1302 files.sort();
1303 Ok(files)
1304}
1305
1306fn collect_paths(directory: &Path) -> Result<Vec<PathBuf>, ProjectError> {
1307 if !directory.exists() {
1308 return Ok(Vec::new());
1309 }
1310 let mut paths = fs::read_dir(directory)
1311 .map_err(|source| ProjectError::Io {
1312 path: directory.to_path_buf(),
1313 source,
1314 })?
1315 .map(|entry| {
1316 entry
1317 .map(|entry| entry.path())
1318 .map_err(|source| ProjectError::Io {
1319 path: directory.to_path_buf(),
1320 source,
1321 })
1322 })
1323 .collect::<Result<Vec<_>, _>>()?;
1324 paths.retain(|path| path.extension().and_then(|value| value.to_str()) == Some("rhai"));
1325 paths.sort();
1326 Ok(paths)
1327}
1328
1329fn generated_embed_module(
1330 manifest: &AppManifest,
1331 modules: &[(String, String)],
1332 locales: &[(String, String)],
1333 themes: &[(String, String)],
1334 assets: &[(String, String)],
1335 fonts: &[(String, String)],
1336) -> Result<String, ProjectError> {
1337 let mut output =
1338 String::from("// @generated by `gpui-rhai embed`; edit files under `ui/` instead.\n\n");
1339 let _ = writeln!(
1340 output,
1341 "pub fn app_manifest() -> gpui_rhai::AppManifest {{\n let mut manifest = gpui_rhai::AppManifest::new(gpui_rhai::ModuleId::parse({:?}).expect(\"generated entry id\"));",
1342 manifest.entry.as_str()
1343 );
1344 for (id, requirement) in &manifest.capabilities {
1345 let _ = writeln!(
1346 output,
1347 " manifest = manifest.with_capability({:?}, {:?}).expect(\"generated capability requirement\");",
1348 id.as_str(),
1349 requirement.to_string()
1350 );
1351 }
1352 output.push_str(" manifest\n}\n\n");
1353 output.push_str("pub fn script_source() -> gpui_rhai::EmbeddedScriptSource {\n");
1354 output
1355 .push_str(" gpui_rhai::EmbeddedScriptSource::new(std::collections::BTreeMap::from([\n");
1356 for (id, path) in modules {
1357 let _ = writeln!(
1358 output,
1359 " (gpui_rhai::ModuleId::parse({id:?}).expect(\"generated module id\"), include_str!({path:?}).to_owned()),"
1360 );
1361 }
1362 output.push_str(" ]))\n}\n\n");
1363 output.push_str("pub const THEME_SOURCE: &str = include_str!(\"../ui/theme.rhai\");\n\n");
1364 output.push_str(
1365 "pub const COMPONENT_STYLES_SOURCE: &str = include_str!(\"../ui/styles.rhai\");\n\n",
1366 );
1367 output.push_str("pub const LOCALES: &[(&str, &str)] = &[\n");
1368 for (name, path) in locales {
1369 let _ = writeln!(output, " ({name:?}, include_str!({path:?})),");
1370 }
1371 output.push_str("];\n\n");
1372 output.push_str("pub const THEMES: &[(&str, &str)] = &[\n");
1373 for (name, path) in themes {
1374 let _ = writeln!(output, " ({name:?}, include_str!({path:?})),");
1375 }
1376 output.push_str("];\n\n");
1377 output.push_str("pub const ASSETS: &[(&str, &[u8])] = &[\n");
1378 for (name, path) in assets {
1379 let _ = writeln!(output, " ({name:?}, include_bytes!({path:?})),");
1380 }
1381 output.push_str("];\n\n");
1382 output.push_str("pub fn asset_sources() -> Vec<(String, gpui_rhai::AssetData)> {\n vec![\n");
1383 let mut logical_assets = BTreeSet::new();
1384 for (name, path) in assets {
1385 let mime_type = embedded_asset_mime(name)?;
1386 let logical_name = name
1387 .rsplit_once('.')
1388 .map_or(name.as_str(), |(stem, _)| stem);
1389 if !logical_assets.insert(logical_name) {
1390 return Err(ProjectError::DuplicateAssetLogical(logical_name.to_owned()));
1391 }
1392 let _ = writeln!(
1393 output,
1394 " ({logical_name:?}.to_owned(), gpui_rhai::AssetData {{ mime_type: {mime_type:?}.to_owned(), bytes: include_bytes!({path:?}).to_vec() }}),"
1395 );
1396 }
1397 output.push_str(" ]\n}\n");
1398 output.push_str("\npub const FONTS: &[(&str, &[u8])] = &[\n");
1399 for (name, path) in fonts {
1400 validate_font_extension(name)?;
1401 let _ = writeln!(output, " ({name:?}, include_bytes!({path:?})),");
1402 }
1403 output.push_str("];\n\n");
1404 output.push_str("pub fn font_sources() -> Vec<gpui_rhai::FontSource> {\n vec![\n");
1405 for (name, path) in fonts {
1406 let _ = writeln!(
1407 output,
1408 " gpui_rhai::FontSource::new({name:?}, include_bytes!({path:?}).to_vec()).expect(\"generated font source\"),"
1409 );
1410 }
1411 output.push_str(" ]\n}\n");
1412 Ok(output)
1413}
1414
1415fn validate_font_extension(path: &str) -> Result<(), ProjectError> {
1416 match path.rsplit('.').next().unwrap_or_default() {
1417 "ttf" | "otf" | "ttc" => Ok(()),
1418 _ => Err(ProjectError::UnsupportedFont(path.to_owned())),
1419 }
1420}
1421
1422fn embedded_asset_mime(path: &str) -> Result<&'static str, ProjectError> {
1423 match path.rsplit('.').next().unwrap_or_default() {
1424 "svg" => Ok("image/svg+xml"),
1425 "png" => Ok("image/png"),
1426 "jpg" | "jpeg" => Ok("image/jpeg"),
1427 "gif" => Ok("image/gif"),
1428 "webp" => Ok("image/webp"),
1429 "bmp" => Ok("image/bmp"),
1430 "tif" | "tiff" => Ok("image/tiff"),
1431 _ => Err(ProjectError::UnsupportedAsset(path.to_owned())),
1432 }
1433}
1434
1435fn host_source() -> String {
1436 r#"fn main() {
1437 let view = gpui_rhai::FileScriptView::new("ui/main.rhai")
1438 .prepare()
1439 .expect("GPUI Rhai view preparation failed");
1440 gpui_rhai::ScriptApplication::new(view)
1441 .run()
1442 .expect("GPUI Rhai application failed");
1443}
1444"#
1445 .to_owned()
1446}
1447
1448fn starter_ui() -> String {
1449 r#"fn view(ctx) {
1450 column([
1451 text("GPUI Rhai"),
1452 text("Run `gpui-rhai add button label` to install source components.")
1453 ]).with_style(style().gap(px(8)).padding(px(16)))
1454}
1455"#
1456 .to_owned()
1457}
1458
1459fn app_manifest_source() -> Result<String, ProjectError> {
1460 Ok(toml::to_string_pretty(&AppManifest {
1461 entry: ModuleId::parse("main")?,
1462 runtime_api: RUNTIME_API_VERSION,
1463 capabilities: BTreeMap::new(),
1464 })?)
1465}
1466
1467fn read(path: &Path) -> Result<String, ProjectError> {
1468 fs::read_to_string(path).map_err(|source| ProjectError::Io {
1469 path: path.to_path_buf(),
1470 source,
1471 })
1472}
1473
1474#[derive(Clone, Debug)]
1475enum ExpectedFile {
1476 Missing,
1477 Exact(String),
1478}
1479
1480#[derive(Clone, Debug)]
1481struct PlannedWrite {
1482 path: PathBuf,
1483 content: String,
1484 expected: ExpectedFile,
1485}
1486
1487struct StagedProjectWrite<'a> {
1488 write: &'a PlannedWrite,
1489 temporary: PathBuf,
1490 backup: Option<PathBuf>,
1491 committed: bool,
1492}
1493
1494struct ProjectApplyLock {
1495 path: PathBuf,
1496}
1497
1498impl ProjectApplyLock {
1499 fn acquire(root: &Path) -> Result<Self, ProjectError> {
1500 let path = root.join(".gpui-rhai-apply.lock");
1501 let mut lock = match OpenOptions::new().write(true).create_new(true).open(&path) {
1502 Ok(lock) => lock,
1503 Err(source) if source.kind() == std::io::ErrorKind::AlreadyExists => {
1504 return Err(ProjectError::ConcurrentChange {
1505 path,
1506 detail: "another apply is active or crash recovery is required".to_owned(),
1507 });
1508 }
1509 Err(source) => {
1510 return Err(ProjectError::Io { path, source });
1511 }
1512 };
1513 let guard = Self { path: path.clone() };
1514 writeln!(lock, "pid={}", std::process::id()).map_err(|source| ProjectError::Io {
1515 path: path.clone(),
1516 source,
1517 })?;
1518 Ok(guard)
1519 }
1520}
1521
1522impl Drop for ProjectApplyLock {
1523 fn drop(&mut self) {
1524 let _ = fs::remove_file(&self.path);
1525 }
1526}
1527
1528#[derive(Clone, Debug)]
1529pub struct ProjectPlan {
1530 root: PathBuf,
1531 writes: Vec<PlannedWrite>,
1532 conflicts: Vec<ModuleId>,
1533}
1534
1535impl ProjectPlan {
1536 fn new(root: PathBuf) -> Self {
1537 Self {
1538 root,
1539 writes: Vec::new(),
1540 conflicts: Vec::new(),
1541 }
1542 }
1543
1544 fn create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1545 match fs::read_to_string(&path) {
1546 Ok(existing) if existing == content => Ok(()),
1547 Ok(_) => Err(ProjectError::WouldOverwrite(path)),
1548 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1549 self.writes.push(PlannedWrite {
1550 path,
1551 content,
1552 expected: ExpectedFile::Missing,
1553 });
1554 Ok(())
1555 }
1556 Err(source) => Err(ProjectError::Io { path, source }),
1557 }
1558 }
1559
1560 fn update(&mut self, path: PathBuf, content: String, existing: String) {
1561 if content != existing {
1562 self.writes.push(PlannedWrite {
1563 path,
1564 content,
1565 expected: ExpectedFile::Exact(existing),
1566 });
1567 }
1568 }
1569
1570 fn replace_or_create(&mut self, path: PathBuf, content: String) -> Result<(), ProjectError> {
1571 match fs::read_to_string(&path) {
1572 Ok(existing) => {
1573 self.update(path, content, existing);
1574 Ok(())
1575 }
1576 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1577 self.create(path, content)
1578 }
1579 Err(source) => Err(ProjectError::Io { path, source }),
1580 }
1581 }
1582
1583 #[must_use]
1584 pub fn conflicts(&self) -> &[ModuleId] {
1585 &self.conflicts
1586 }
1587
1588 #[must_use]
1589 pub fn summary(&self) -> String {
1590 let mut lines = if self.writes.is_empty() {
1591 vec!["No filesystem changes required.".to_owned()]
1592 } else {
1593 vec![format!("{} file change(s):", self.writes.len())]
1594 };
1595 lines.extend(self.writes.iter().map(|write| {
1596 format!(
1597 "- {} {}",
1598 match &write.expected {
1599 ExpectedFile::Missing => "create",
1600 ExpectedFile::Exact(_) => "update",
1601 },
1602 write
1603 .path
1604 .strip_prefix(&self.root)
1605 .unwrap_or(&write.path)
1606 .display()
1607 )
1608 }));
1609 if !self.conflicts.is_empty() {
1610 lines.push(format!(
1611 "{} merge conflict(s): {}",
1612 self.conflicts.len(),
1613 self.conflicts
1614 .iter()
1615 .map(ToString::to_string)
1616 .collect::<Vec<_>>()
1617 .join(", ")
1618 ));
1619 }
1620 lines.join("\n")
1621 }
1622
1623 #[allow(clippy::too_many_lines)] pub fn apply(self) -> Result<(), ProjectError> {
1631 let _lock = ProjectApplyLock::acquire(&self.root)?;
1632 for write in &self.writes {
1633 match (&write.expected, fs::read_to_string(&write.path)) {
1634 (ExpectedFile::Missing, Err(error))
1635 if error.kind() == std::io::ErrorKind::NotFound => {}
1636 (ExpectedFile::Exact(expected), Ok(actual)) if expected == &actual => {}
1637 (_, result) => {
1638 return Err(ProjectError::ConcurrentChange {
1639 path: write.path.clone(),
1640 detail: result.map_or_else(
1641 |error| error.to_string(),
1642 |_| "content changed".to_owned(),
1643 ),
1644 });
1645 }
1646 }
1647 }
1648 let nonce = std::time::SystemTime::now()
1649 .duration_since(std::time::UNIX_EPOCH)
1650 .unwrap_or_default()
1651 .as_nanos();
1652 let mut staged = Vec::with_capacity(self.writes.len());
1653 for (index, write) in self.writes.iter().enumerate() {
1654 let parent = write
1655 .path
1656 .parent()
1657 .ok_or_else(|| ProjectError::InvalidPath(write.path.clone()))?;
1658 fs::create_dir_all(parent).map_err(|source| ProjectError::Io {
1659 path: parent.to_path_buf(),
1660 source,
1661 })?;
1662 let temporary = parent.join(format!(
1663 ".gpui-rhai-tmp-{}-{nonce}-{index}",
1664 std::process::id()
1665 ));
1666 let mut file = match OpenOptions::new()
1667 .write(true)
1668 .create_new(true)
1669 .open(&temporary)
1670 {
1671 Ok(file) => file,
1672 Err(source) => {
1673 cleanup_staged_files(&staged);
1674 return Err(ProjectError::Io {
1675 path: temporary,
1676 source,
1677 });
1678 }
1679 };
1680 if let Err(source) = file.write_all(write.content.as_bytes()) {
1681 let _ = fs::remove_file(&temporary);
1682 cleanup_staged_files(&staged);
1683 return Err(ProjectError::Io {
1684 path: temporary,
1685 source,
1686 });
1687 }
1688 staged.push(StagedProjectWrite {
1689 write,
1690 temporary,
1691 backup: matches!(write.expected, ExpectedFile::Exact(_)).then(|| {
1692 parent.join(format!(
1693 ".gpui-rhai-backup-{}-{nonce}-{index}",
1694 std::process::id()
1695 ))
1696 }),
1697 committed: false,
1698 });
1699 }
1700
1701 for staged_write in &staged {
1704 if !expectation_matches(staged_write.write) {
1705 cleanup_staged_files(&staged);
1706 return Err(ProjectError::ConcurrentChange {
1707 path: staged_write.write.path.clone(),
1708 detail: "content changed while staging the update".to_owned(),
1709 });
1710 }
1711 }
1712
1713 for index in 0..staged.len() {
1714 let commit = (|| -> Result<(), std::io::Error> {
1715 if let Some(backup) = &staged[index].backup {
1716 fs::rename(&staged[index].write.path, backup)?;
1717 }
1718 fs::rename(&staged[index].temporary, &staged[index].write.path)?;
1719 staged[index].committed = true;
1720 Ok(())
1721 })();
1722 if let Err(source) = commit {
1723 let rollback = rollback_staged_files(&mut staged, index);
1724 if let Err(rollback) = rollback {
1725 return Err(ProjectError::ApplyRollback {
1726 path: staged[index].write.path.clone(),
1727 source,
1728 rollback,
1729 });
1730 }
1731 return Err(ProjectError::Io {
1732 path: staged[index].write.path.clone(),
1733 source,
1734 });
1735 }
1736 }
1737 for staged_write in &staged {
1738 if let Some(backup) = &staged_write.backup {
1739 let _ = fs::remove_file(backup);
1740 }
1741 }
1742 Ok(())
1743 }
1744}
1745
1746fn expectation_matches(write: &PlannedWrite) -> bool {
1747 match (&write.expected, fs::read_to_string(&write.path)) {
1748 (ExpectedFile::Missing, Err(error)) if error.kind() == std::io::ErrorKind::NotFound => true,
1749 (ExpectedFile::Exact(expected), Ok(actual)) => expected == &actual,
1750 _ => false,
1751 }
1752}
1753
1754fn cleanup_staged_files(staged: &[StagedProjectWrite<'_>]) {
1755 for staged in staged {
1756 let _ = fs::remove_file(&staged.temporary);
1757 }
1758}
1759
1760fn rollback_staged_files(
1761 staged: &mut [StagedProjectWrite<'_>],
1762 failed: usize,
1763) -> Result<(), String> {
1764 let mut errors = Vec::new();
1765 for index in (0..=failed).rev() {
1766 let item = &mut staged[index];
1767 if item.committed
1768 && let Err(error) = fs::remove_file(&item.write.path)
1769 {
1770 errors.push(format!("remove {}: {error}", item.write.path.display()));
1771 }
1772 if let Some(backup) = &item.backup
1773 && backup.exists()
1774 && let Err(error) = fs::rename(backup, &item.write.path)
1775 {
1776 errors.push(format!(
1777 "restore {} from {}: {error}",
1778 item.write.path.display(),
1779 backup.display()
1780 ));
1781 }
1782 if item.temporary.exists()
1783 && let Err(error) = fs::remove_file(&item.temporary)
1784 {
1785 errors.push(format!("remove {}: {error}", item.temporary.display()));
1786 }
1787 }
1788 for item in staged.iter().skip(failed + 1) {
1789 if let Err(error) = fs::remove_file(&item.temporary) {
1790 errors.push(format!("remove {}: {error}", item.temporary.display()));
1791 }
1792 }
1793 if errors.is_empty() {
1794 Ok(())
1795 } else {
1796 Err(errors.join("; "))
1797 }
1798}
1799
1800#[derive(Clone, Debug, Serialize, Deserialize)]
1801struct LocalManifest {
1802 runtime_api: u32,
1803 #[serde(default)]
1804 components: BTreeMap<String, InstalledComponent>,
1805}
1806
1807impl Default for LocalManifest {
1808 fn default() -> Self {
1809 Self {
1810 runtime_api: RUNTIME_API_VERSION,
1811 components: BTreeMap::new(),
1812 }
1813 }
1814}
1815
1816#[derive(Clone, Debug, Serialize, Deserialize)]
1817struct InstalledComponent {
1818 version: Version,
1819 content_hash: String,
1820 #[serde(default)]
1821 dependencies: Vec<String>,
1822}
1823
1824#[derive(Clone, Debug)]
1825pub struct CheckReport {
1826 pub components: usize,
1827 pub entry: ModuleId,
1828}
1829
1830impl CheckReport {
1831 #[must_use]
1832 pub fn summary(&self) -> String {
1833 format!(
1834 "Check passed: entry `{}`, {} installed component(s).",
1835 self.entry, self.components
1836 )
1837 }
1838}
1839
1840#[derive(Debug, Error)]
1841pub enum ProjectError {
1842 #[error("I/O failed for `{path}`: {source}")]
1843 Io {
1844 path: PathBuf,
1845 #[source]
1846 source: std::io::Error,
1847 },
1848 #[error("I/O failed for `{path}`: {source}; rollback was incomplete: {rollback}")]
1849 ApplyRollback {
1850 path: PathBuf,
1851 #[source]
1852 source: std::io::Error,
1853 rollback: String,
1854 },
1855 #[error("invalid Cargo.toml `{path}`: {source}")]
1856 CargoToml {
1857 path: PathBuf,
1858 source: toml_edit::TomlError,
1859 },
1860 #[error("Cargo.toml `{0}` has no gpui-rhai dependency to configure")]
1861 MissingRuntimeDependency(PathBuf),
1862 #[error("Cargo.toml `{0}` uses an unsupported gpui-rhai dependency shape")]
1863 InvalidRuntimeDependency(PathBuf),
1864 #[error("Cargo.toml `{0}` has a non-array gpui-rhai feature list")]
1865 InvalidRuntimeFeatures(PathBuf),
1866 #[error("incompatible GPUI dependency family: {0}")]
1867 GpuiDependency(String),
1868 #[error("initialization would overwrite existing file `{0}`")]
1869 WouldOverwrite(PathBuf),
1870 #[error("file `{path}` changed after planning: {detail}")]
1871 ConcurrentChange { path: PathBuf, detail: String },
1872 #[error("path `{0}` has no parent directory")]
1873 InvalidPath(PathBuf),
1874 #[error("component registry contains duplicate `{0}`")]
1875 DuplicateRegistry(ModuleId),
1876 #[error("component catalog id `{catalog}` differs from source metadata `{metadata}`")]
1877 RegistryIdMismatch { catalog: String, metadata: ModuleId },
1878 #[error(
1879 "component `{component}` asset metadata differs from bundled assets: declared {declared:?}, bundled {bundled:?}"
1880 )]
1881 RegistryAssetMismatch {
1882 component: ModuleId,
1883 declared: BTreeSet<String>,
1884 bundled: BTreeSet<String>,
1885 },
1886 #[error("component `{0}` is not in the bundled registry")]
1887 UnknownComponent(ModuleId),
1888 #[error("component registry dependency cycle: {0:?}")]
1889 RegistryCycle(Vec<ModuleId>),
1890 #[error("at least one component name is required")]
1891 NoComponents,
1892 #[error("component `{0}` is incompatible with this runtime API")]
1893 IncompatibleRuntime(ModuleId),
1894 #[error("module `{0}` is not an official component path")]
1895 InvalidComponentPath(ModuleId),
1896 #[error("baseline hash for `{0}` does not match the install manifest")]
1897 BaselineHash(ModuleId),
1898 #[error("asset `{0}` has source without a baseline or a baseline without source")]
1899 IncompleteAssetBaseline(String),
1900 #[error("installed metadata for `{0}` does not match its manifest")]
1901 InstalledMetadata(ModuleId),
1902 #[error("component `{0}` did not call define_component")]
1903 MissingExport(ModuleId),
1904 #[error("component `{component}` source documentation is missing {missing}")]
1905 ComponentDocumentation {
1906 component: ModuleId,
1907 missing: &'static str,
1908 },
1909 #[error("application entry must declare `view(ctx)`")]
1910 MissingView,
1911 #[error("Rhai known-call validation failed:\n{0}")]
1912 KnownCalls(String),
1913 #[error("manifest requires runtime API {required}, current API is {actual}")]
1914 ManifestRuntime { required: u32, actual: u32 },
1915 #[error("generated host requires manifest entry `main`, got `{0}`")]
1916 ManifestEntry(ModuleId),
1917 #[error("embedded asset `{0}` has an unsupported image extension")]
1918 UnsupportedAsset(String),
1919 #[error("font `{0}` has an unsupported extension; expected ttf, otf, or ttc")]
1920 UnsupportedFont(String),
1921 #[error("font validation failed: {0}")]
1922 Font(String),
1923 #[error("asset validation failed: {0}")]
1924 Asset(String),
1925 #[error("embedded assets resolve to duplicate logical ID `app/{0}`")]
1926 DuplicateAssetLogical(String),
1927 #[error("theme validation failed: {0}")]
1928 Theme(String),
1929 #[error("component stylesheet validation failed: {0}")]
1930 ComponentStyle(String),
1931 #[error("locale validation failed: {0}")]
1932 Locale(String),
1933 #[error("component update conflicts require inspection: {0:?}")]
1934 UpdateConflictsWritten(Vec<ModuleId>),
1935 #[error("failed to start Cargo: {0}")]
1936 Spawn(std::io::Error),
1937 #[error("Cargo application exited unsuccessfully with code {0:?}")]
1938 DevFailed(Option<i32>),
1939 #[error(transparent)]
1940 ModuleId(#[from] gpui_rhai::ModuleIdError),
1941 #[error(transparent)]
1942 Header(#[from] gpui_rhai::ComponentHeaderError),
1943 #[error(transparent)]
1944 Component(#[from] gpui_rhai::ComponentError),
1945 #[error(transparent)]
1946 ComponentExport(#[from] gpui_rhai::ComponentExportError),
1947 #[error(transparent)]
1948 Capability(#[from] gpui_rhai::CapabilityError),
1949 #[error(transparent)]
1950 Runtime(#[from] gpui_rhai::RuntimeError),
1951 #[error(transparent)]
1952 KnownCallLint(#[from] gpui_rhai::KnownCallLintError),
1953 #[error(transparent)]
1954 Lifecycle(#[from] gpui_rhai::LifecycleError),
1955 #[error(transparent)]
1956 Source(#[from] gpui_rhai::ScriptSourceError),
1957 #[error(transparent)]
1958 TomlSerialize(#[from] toml::ser::Error),
1959 #[error(transparent)]
1960 TomlDeserialize(#[from] toml::de::Error),
1961 #[error(transparent)]
1962 JsonSerialize(#[from] serde_json::Error),
1963 #[error("Theme Studio failed: {0}")]
1964 ThemeStudio(String),
1965 #[error("Gallery failed: {0}")]
1966 Gallery(String),
1967}
1968
1969#[cfg(test)]
1970mod tests {
1971 use super::*;
1972
1973 fn fixture() -> tempfile::TempDir {
1974 let directory = tempfile::tempdir().unwrap();
1975 fs::create_dir_all(directory.path().join("src")).unwrap();
1976 fs::write(
1977 directory.path().join("Cargo.toml"),
1978 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n",
1979 )
1980 .unwrap();
1981 directory
1982 }
1983
1984 #[test]
1985 fn bundled_component_icons_share_an_explicit_24px_coordinate_space() {
1986 for (name, source) in [
1987 ("check", CHECK_SVG),
1988 ("close", CLOSE_SVG),
1989 ("chevron_left", CHEVRON_LEFT_SVG),
1990 ("chevron_right", CHEVRON_RIGHT_SVG),
1991 ("calendar", CALENDAR_SVG),
1992 ("date_previous", DATE_PREVIOUS_SVG),
1993 ("date_next", DATE_NEXT_SVG),
1994 ("disclosure_down", DISCLOSURE_DOWN_SVG),
1995 ("sort_ascending", SORT_ASCENDING_SVG),
1996 ("sort_descending", SORT_DESCENDING_SVG),
1997 ("chevron_down", CHEVRON_DOWN_SVG),
1998 ("chevron_up", CHEVRON_UP_SVG),
1999 ("minus", MINUS_SVG),
2000 ("plus", PLUS_SVG),
2001 ("search", SEARCH_SVG),
2002 ("info", INFO_SVG),
2003 ("warning", WARNING_SVG),
2004 ("help", HELP_SVG),
2005 ] {
2006 assert!(source.contains("width=\"24\""), "{name} has no 24px width");
2007 assert!(
2008 source.contains("height=\"24\""),
2009 "{name} has no 24px height"
2010 );
2011 assert!(
2012 source.contains("viewBox=\"0 0 24 24\""),
2013 "{name} has a non-standard viewBox"
2014 );
2015 }
2016 }
2017
2018 #[test]
2019 fn dry_run_plan_has_zero_writes_until_applied() {
2020 let directory = fixture();
2021 let project = Project::new(directory.path());
2022 let plan = project.plan_init().unwrap();
2023 assert!(!plan.writes.is_empty());
2024 assert!(!directory.path().join("ui/main.rhai").exists());
2025 drop(plan);
2026 assert!(!directory.path().join("ui/main.rhai").exists());
2027 }
2028
2029 #[test]
2030 fn init_add_and_check_form_a_reproducible_flow() {
2031 let directory = fixture();
2032 let project = Project::new(directory.path());
2033 project.plan_init().unwrap().apply().unwrap();
2034 project
2035 .plan_add(
2036 &BundledRegistry::load().unwrap(),
2037 &["button".to_owned(), "label".to_owned()],
2038 )
2039 .unwrap()
2040 .apply()
2041 .unwrap();
2042 let report = project.check().unwrap();
2043 assert_eq!(report.components, 2);
2044 assert!(
2045 directory
2046 .path()
2047 .join(".gpui-rhai/baselines/components/button.rhai")
2048 .exists()
2049 );
2050 }
2051
2052 #[test]
2053 fn check_rejects_old_or_misaligned_gpui_package_identity() {
2054 let directory = fixture();
2055 let cargo = directory.path().join("Cargo.toml");
2056 fs::write(
2057 &cargo,
2058 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = \"=0.2.2\"\n",
2059 )
2060 .unwrap();
2061 assert!(matches!(
2062 validate_gpui_dependency_family(&cargo),
2063 Err(ProjectError::GpuiDependency(_))
2064 ));
2065
2066 fs::write(
2067 &cargo,
2068 "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n",
2069 )
2070 .unwrap();
2071 validate_gpui_dependency_family(&cargo).unwrap();
2072
2073 fs::write(
2074 &cargo,
2075 "[workspace]\nmembers = []\n\n[workspace.dependencies]\ngpui = { package = \"gpui-pre\", version = \"=0.3.7\", default-features = false }\ngpui_platform = { package = \"gpui-pre-platform\", version = \"=0.3.7\", default-features = false }\n\n[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\ngpui.workspace = true\ngpui_platform.workspace = true\n",
2076 )
2077 .unwrap();
2078 validate_gpui_dependency_family(&cargo).unwrap();
2079 }
2080
2081 #[test]
2082 fn check_validates_the_typed_component_stylesheet() {
2083 let directory = fixture();
2084 let project = Project::new(directory.path());
2085 project.plan_init().unwrap().apply().unwrap();
2086 project
2087 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2088 .unwrap()
2089 .apply()
2090 .unwrap();
2091 fs::write(
2092 directory.path().join("ui/main.rhai"),
2093 r#"import "components/button" as button;
2094 fn view(ctx) { button::Button(#{ text: "Styled" }) }
2095 "#,
2096 )
2097 .unwrap();
2098 let styles = directory.path().join("ui/styles.rhai");
2099 fs::write(
2100 &styles,
2101 r#"fn component_styles() {
2102 #{ "components/button": #{ root: style().height(px(34)) } }
2103 }
2104 "#,
2105 )
2106 .unwrap();
2107 project.check().unwrap();
2108
2109 fs::write(
2110 styles,
2111 r#"fn component_styles() {
2112 #{ "components/button": #{ missing: style() } }
2113 }
2114 "#,
2115 )
2116 .unwrap();
2117 let error = project.check().unwrap_err();
2118 assert!(matches!(error, ProjectError::ComponentStyle(_)));
2119 assert!(error.to_string().contains("unknown part"));
2120 }
2121
2122 #[test]
2123 fn check_executes_the_headless_initial_view() {
2124 let directory = fixture();
2125 let project = Project::new(directory.path());
2126 project.plan_init().unwrap().apply().unwrap();
2127 fs::write(
2128 directory.path().join("ui/main.rhai"),
2129 "fn view(ctx) { throw \"headless render failed\"; }\n",
2130 )
2131 .unwrap();
2132 assert!(matches!(
2133 project.check(),
2134 Err(ProjectError::Lifecycle(_) | ProjectError::Runtime(_))
2135 ));
2136 }
2137
2138 #[test]
2139 fn check_accepts_nested_templates_and_imports_after_them() {
2140 let directory = fixture();
2141 let project = Project::new(directory.path());
2142 project.plan_init().unwrap().apply().unwrap();
2143 project
2144 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2145 .unwrap()
2146 .apply()
2147 .unwrap();
2148 fs::write(
2149 directory.path().join("ui/main.rhai"),
2150 r#"
2151 fn nested_label() {
2152 `外层${if true { "中文" } else { `内层${1}` }}结束`
2153 }
2154 import "components/button" as button;
2155 fn view(ctx) {
2156 column([
2157 text(nested_label()),
2158 button::Button(#{ text: "Ready" }),
2159 ])
2160 }
2161 "#,
2162 )
2163 .unwrap();
2164
2165 let report = project.check().unwrap();
2166 assert_eq!(report.components, 1);
2167 }
2168
2169 #[test]
2170 fn check_lints_known_calls_in_unexecuted_branches() {
2171 let directory = fixture();
2172 let project = Project::new(directory.path());
2173 project.plan_init().unwrap().apply().unwrap();
2174 fs::write(
2175 directory.path().join("ui/main.rhai"),
2176 r#"
2177 fn view(ctx) {
2178 if false { text(); }
2179 text("still renders")
2180 }
2181 "#,
2182 )
2183 .unwrap();
2184
2185 let error = project.check().unwrap_err();
2186 assert!(matches!(error, ProjectError::KnownCalls(_)));
2187 assert!(error.to_string().contains("text/0"));
2188 assert!(error.to_string().contains("expected 1"));
2189 }
2190
2191 #[test]
2192 fn init_never_overwrites_existing_main() {
2193 let directory = fixture();
2194 fs::write(directory.path().join("src/main.rs"), "fn main() {}\n").unwrap();
2195 let project = Project::new(directory.path());
2196 project.plan_init().unwrap().apply().unwrap();
2197 assert_eq!(
2198 read(&directory.path().join("src/main.rs")).unwrap(),
2199 "fn main() {}\n"
2200 );
2201 assert!(directory.path().join("gpui-rhai-host-snippet.rs").exists());
2202 }
2203
2204 #[test]
2205 fn apply_detects_changes_after_planning() {
2206 let directory = fixture();
2207 let project = Project::new(directory.path());
2208 let plan = project.plan_init().unwrap();
2209 fs::write(
2210 directory.path().join("Cargo.toml"),
2211 "[package]\nname = \"changed\"\nversion = \"0.1.0\"\n",
2212 )
2213 .unwrap();
2214 assert!(matches!(
2215 plan.apply(),
2216 Err(ProjectError::ConcurrentChange { .. })
2217 ));
2218 }
2219
2220 #[test]
2221 fn apply_stages_every_write_before_replacing_any_target() {
2222 let directory = fixture();
2223 let original = read(&directory.path().join("Cargo.toml")).unwrap();
2224 let blocked = directory.path().join("blocked");
2225 fs::write(&blocked, "not a directory").unwrap();
2226 let mut plan = ProjectPlan::new(directory.path().to_path_buf());
2227 plan.update(
2228 directory.path().join("Cargo.toml"),
2229 format!("{original}\n# planned\n"),
2230 original.clone(),
2231 );
2232 plan.writes.push(PlannedWrite {
2233 path: blocked.join("child.rhai"),
2234 content: "fn view() {}".to_owned(),
2235 expected: ExpectedFile::Missing,
2236 });
2237 assert!(plan.apply().is_err());
2238 assert_eq!(
2239 read(&directory.path().join("Cargo.toml")).unwrap(),
2240 original
2241 );
2242 }
2243
2244 #[test]
2245 fn embed_plan_contains_entry_components_theme_locale_and_assets() {
2246 let directory = fixture();
2247 let project = Project::new(directory.path());
2248 project.plan_init().unwrap().apply().unwrap();
2249 project
2250 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2251 .unwrap()
2252 .apply()
2253 .unwrap();
2254 fs::create_dir_all(directory.path().join("ui/locales")).unwrap();
2255 fs::create_dir_all(directory.path().join("ui/assets")).unwrap();
2256 fs::create_dir_all(directory.path().join("ui/fonts")).unwrap();
2257 fs::write(directory.path().join("ui/locales/en.rhai"), "#{}\n").unwrap();
2258 fs::write(directory.path().join("ui/assets/check.svg"), "<svg/>\n").unwrap();
2259 fs::write(
2260 directory.path().join("ui/fonts/art.otf"),
2261 [b"OTTO".as_slice(), &[0, 1, 2]].concat(),
2262 )
2263 .unwrap();
2264
2265 project.plan_embed().unwrap().apply().unwrap();
2266 let generated = read(&directory.path().join("src/gpui_rhai_embedded.rs")).unwrap();
2267 assert!(generated.contains("pub fn app_manifest()"));
2268 assert!(generated.contains("AppManifest::new"));
2269 assert!(generated.contains("pub fn asset_sources()"));
2270 assert!(generated.contains("pub fn font_sources()"));
2271 assert!(generated.contains("image/svg+xml"));
2272 assert!(generated.contains("components/button"));
2273 assert!(generated.contains("THEME_SOURCE"));
2274 assert!(generated.contains("COMPONENT_STYLES_SOURCE"));
2275 assert!(generated.contains("locales/en.rhai"));
2276 assert!(generated.contains("assets/check.svg"));
2277 assert!(generated.contains("fonts/art.otf"));
2278 }
2279
2280 #[test]
2281 fn adding_icon_installs_core_svg_asset_pack() {
2282 let directory = fixture();
2283 let project = Project::new(directory.path());
2284 project.plan_init().unwrap().apply().unwrap();
2285 project
2286 .plan_add(&BundledRegistry::load().unwrap(), &["icon".to_owned()])
2287 .unwrap()
2288 .apply()
2289 .unwrap();
2290 project.check().unwrap();
2291 assert!(directory.path().join("ui/assets/icons/check.svg").exists());
2292 assert!(directory.path().join("ui/assets/icons/close.svg").exists());
2293 assert!(directory.path().join("ui/assets/icons/search.svg").exists());
2294 assert!(
2295 directory
2296 .path()
2297 .join(".gpui-rhai/baselines/assets/icons/check.svg")
2298 .exists()
2299 );
2300 }
2301
2302 #[test]
2303 fn adding_chart_installs_source_and_enables_the_runtime_feature() {
2304 let directory = fixture();
2305 let project = Project::new(directory.path());
2306 project.plan_init().unwrap().apply().unwrap();
2307 project
2308 .plan_add(&BundledRegistry::load().unwrap(), &["bar_chart".to_owned()])
2309 .unwrap()
2310 .apply()
2311 .unwrap();
2312
2313 assert!(directory.path().join("ui/charts/chart.rhai").exists());
2314 assert!(directory.path().join("ui/charts/bar_chart.rhai").exists());
2315 let cargo = read(&directory.path().join("Cargo.toml")).unwrap();
2316 let cargo = cargo.parse::<DocumentMut>().unwrap();
2317 let features = cargo["dependencies"]["gpui-rhai"]["features"]
2318 .as_array()
2319 .unwrap();
2320 assert!(
2321 features
2322 .iter()
2323 .any(|value| value.as_str() == Some("charts"))
2324 );
2325 project.check().unwrap();
2326 }
2327
2328 #[test]
2329 fn complex_components_install_transitive_sources_and_assets() {
2330 let directory = fixture();
2331 let project = Project::new(directory.path());
2332 project.plan_init().unwrap().apply().unwrap();
2333 project
2334 .plan_add(
2335 &BundledRegistry::load().unwrap(),
2336 &[
2337 "pagination".to_owned(),
2338 "table".to_owned(),
2339 "textarea".to_owned(),
2340 "date_picker".to_owned(),
2341 ],
2342 )
2343 .unwrap()
2344 .apply()
2345 .unwrap();
2346 let report = project.check().unwrap();
2347 assert_eq!(report.components, 10);
2348 for component in [
2349 "badge.rhai",
2350 "button.rhai",
2351 "icon.rhai",
2352 "input.rhai",
2353 "combobox.rhai",
2354 "select.rhai",
2355 "pagination.rhai",
2356 "table.rhai",
2357 "textarea.rhai",
2358 "date_picker.rhai",
2359 ] {
2360 assert!(
2361 directory
2362 .path()
2363 .join("ui/components")
2364 .join(component)
2365 .exists()
2366 );
2367 }
2368 for asset in [
2369 "icons/check.svg",
2370 "icons/close.svg",
2371 "icons/search.svg",
2372 "icons/chevron_left.svg",
2373 "icons/chevron_right.svg",
2374 "icons/chevron_down.svg",
2375 "icons/chevron_up.svg",
2376 "icons/calendar.svg",
2377 "icons/date_previous.svg",
2378 "icons/date_next.svg",
2379 "icons/disclosure_down.svg",
2380 "icons/sort_ascending.svg",
2381 "icons/sort_descending.svg",
2382 ] {
2383 assert!(directory.path().join("ui/assets").join(asset).exists());
2384 }
2385 }
2386
2387 #[test]
2388 fn every_bundled_component_passes_known_call_validation() {
2389 let directory = fixture();
2390 let project = Project::new(directory.path());
2391 project.plan_init().unwrap().apply().unwrap();
2392 let registry = BundledRegistry::load().unwrap();
2393 assert_eq!(registry.entries.len(), 68);
2394 let requested = registry
2395 .entries
2396 .keys()
2397 .map(ToString::to_string)
2398 .collect::<Vec<_>>();
2399 project
2400 .plan_add(®istry, &requested)
2401 .unwrap()
2402 .apply()
2403 .unwrap();
2404
2405 let report = project.check().unwrap();
2406 assert_eq!(report.components, registry.entries.len());
2407 }
2408
2409 #[test]
2410 fn check_rejects_invalid_locales_and_duplicate_logical_assets() {
2411 let directory = fixture();
2412 let project = Project::new(directory.path());
2413 project.plan_init().unwrap().apply().unwrap();
2414 fs::write(
2415 directory.path().join("ui/locales/en.rhai"),
2416 "fn locale() { #{ locale: \"en\", direction: \"left_to_right\", messages: #{} } }",
2417 )
2418 .unwrap();
2419 assert!(matches!(project.check(), Err(ProjectError::Locale(_))));
2420
2421 fs::write(directory.path().join("ui/locales/en.rhai"), EN_LOCALE).unwrap();
2422 fs::create_dir_all(directory.path().join("ui/assets/icons")).unwrap();
2423 fs::write(directory.path().join("ui/assets/icons/check.svg"), "<svg/>").unwrap();
2424 fs::write(directory.path().join("ui/assets/icons/check.png"), [0u8]).unwrap();
2425 assert!(matches!(
2426 project.check(),
2427 Err(ProjectError::DuplicateAssetLogical(name)) if name == "icons/check"
2428 ));
2429 }
2430
2431 fn bumped_button_registry(upstream_purpose: &str) -> BundledRegistry {
2432 let mut registry = BundledRegistry::load().unwrap();
2433 let id = ModuleId::parse("components/button").unwrap();
2434 let entry = registry.entries.get_mut(&id).unwrap();
2435 let installed_version = entry.metadata.version.to_string();
2436 let source = entry
2437 .source
2438 .replace(&installed_version, "0.2.0")
2439 .replace("// Button presents a desktop action.", upstream_purpose);
2440 let source: &'static str = Box::leak(source.into_boxed_str());
2441 entry.metadata = parse_component_header(source).unwrap();
2442 entry.source = source;
2443 registry
2444 }
2445
2446 #[test]
2447 fn update_three_way_merges_non_overlapping_local_changes() {
2448 let directory = fixture();
2449 let project = Project::new(directory.path());
2450 project.plan_init().unwrap().apply().unwrap();
2451 project
2452 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2453 .unwrap()
2454 .apply()
2455 .unwrap();
2456 let source_path = directory.path().join("ui/components/button.rhai");
2457 let local = format!(
2458 "{}\n// application-owned footer\n",
2459 read(&source_path).unwrap()
2460 );
2461 fs::write(&source_path, &local).unwrap();
2462 let registry = bumped_button_registry("// Button presents a refined desktop action.");
2463 let plan = project.plan_update(®istry).unwrap();
2464 assert!(plan.conflicts().is_empty());
2465 plan.apply().unwrap();
2466
2467 let merged = read(&source_path).unwrap();
2468 assert!(merged.contains("refined desktop action"));
2469 assert!(merged.contains("application-owned footer"));
2470 assert!(merged.contains("0.2.0"));
2471 assert_eq!(
2472 read(
2473 &directory
2474 .path()
2475 .join(".gpui-rhai/baselines/components/button.rhai")
2476 )
2477 .unwrap(),
2478 registry.entries[&ModuleId::parse("components/button").unwrap()].source
2479 );
2480 }
2481
2482 #[test]
2483 fn update_installs_new_transitive_dependencies_and_records_the_target_graph() {
2484 let directory = fixture();
2485 let project = Project::new(directory.path());
2486 project.plan_init().unwrap().apply().unwrap();
2487 project
2488 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2489 .unwrap()
2490 .apply()
2491 .unwrap();
2492
2493 let mut registry = BundledRegistry::load().unwrap();
2494 let button = ModuleId::parse("components/button").unwrap();
2495 let entry = registry.entries.get_mut(&button).unwrap();
2496 let installed_version = entry.metadata.version.to_string();
2497 let source = entry
2498 .source
2499 .replace(&installed_version, "0.2.0")
2500 .replace("dependencies: []", "dependencies: [\"components/badge\"]")
2501 .replace(
2502 "\"dependencies\": []",
2503 "\"dependencies\": [\"components/badge\"]",
2504 );
2505 let source: &'static str = Box::leak(source.into_boxed_str());
2506 entry.metadata = parse_component_header(source).unwrap();
2507 entry.source = source;
2508
2509 project.plan_update(®istry).unwrap().apply().unwrap();
2510 assert!(directory.path().join("ui/components/badge.rhai").exists());
2511 let manifest: LocalManifest =
2512 toml::from_str(&read(&directory.path().join(".gpui-rhai/manifest.toml")).unwrap())
2513 .unwrap();
2514 assert!(manifest.components.contains_key("components/badge"));
2515 assert_eq!(
2516 manifest.components["components/button"].dependencies,
2517 vec!["components/badge"]
2518 );
2519 }
2520
2521 #[test]
2522 fn update_adds_missing_bundled_themes_without_overwriting_owned_sources() {
2523 let directory = fixture();
2524 let project = Project::new(directory.path());
2525 project.plan_init().unwrap().apply().unwrap();
2526 let owned = directory.path().join("ui/themes/nord.rhai");
2527 fs::write(&owned, "// application-owned Nord\n").unwrap();
2528 let missing = directory.path().join("ui/themes/ethereal.rhai");
2529 fs::remove_file(&missing).unwrap();
2530 let styles = directory.path().join("ui/styles.rhai");
2531 fs::remove_file(&styles).unwrap();
2532
2533 project
2534 .plan_update(&BundledRegistry::load().unwrap())
2535 .unwrap()
2536 .apply()
2537 .unwrap();
2538
2539 assert_eq!(read(&owned).unwrap(), "// application-owned Nord\n");
2540 assert_eq!(read(&missing).unwrap(), ETHEREAL_THEME);
2541 assert_eq!(read(&styles).unwrap(), DEFAULT_COMPONENT_STYLES);
2542 }
2543
2544 #[test]
2545 fn update_conflict_preserves_sources_and_writes_artifact() {
2546 let directory = fixture();
2547 let project = Project::new(directory.path());
2548 project.plan_init().unwrap().apply().unwrap();
2549 project
2550 .plan_add(&BundledRegistry::load().unwrap(), &["button".to_owned()])
2551 .unwrap()
2552 .apply()
2553 .unwrap();
2554 let source_path = directory.path().join("ui/components/button.rhai");
2555 let baseline_path = directory
2556 .path()
2557 .join(".gpui-rhai/baselines/components/button.rhai");
2558 let baseline = read(&baseline_path).unwrap();
2559 let local = baseline.replace(
2560 "// Button presents a desktop action.",
2561 "// Button presents the application's custom action.",
2562 );
2563 fs::write(&source_path, &local).unwrap();
2564 let registry = bumped_button_registry("// Button presents the registry action.");
2565 let plan = project.plan_update(®istry).unwrap();
2566 assert_eq!(
2567 plan.conflicts(),
2568 &[ModuleId::parse("components/button").unwrap()]
2569 );
2570 plan.apply().unwrap();
2571
2572 assert_eq!(read(&source_path).unwrap(), local);
2573 assert_eq!(read(&baseline_path).unwrap(), baseline);
2574 let artifact = read(
2575 &directory
2576 .path()
2577 .join(".gpui-rhai/conflicts/components/button.rhai"),
2578 )
2579 .unwrap();
2580 assert!(artifact.contains("<<<<<<< local"));
2581 assert!(artifact.contains("||||||| installed baseline"));
2582 assert!(artifact.contains(">>>>>>> bundled registry"));
2583 }
2584
2585 #[test]
2586 fn line_merge_combines_independent_insertions() {
2587 let merged = three_way_merge(
2588 "alpha\nbeta\n",
2589 "local\nalpha\nbeta\n",
2590 "alpha\nbeta\nupstream\n",
2591 )
2592 .unwrap();
2593 assert_eq!(merged, "local\nalpha\nbeta\nupstream\n");
2594 }
2595
2596 #[test]
2597 fn editor_metadata_comes_from_installed_component_schemas() {
2598 let directory = fixture();
2599 let project = Project::new(directory.path());
2600 project.plan_init().unwrap().apply().unwrap();
2601 project
2602 .plan_add(
2603 &BundledRegistry::load().unwrap(),
2604 &["combobox".to_owned(), "button".to_owned()],
2605 )
2606 .unwrap()
2607 .apply()
2608 .unwrap();
2609 let plan = project.plan_editor_metadata().unwrap();
2610 assert_eq!(plan.writes.len(), 3);
2611 plan.apply().unwrap();
2612
2613 let metadata: serde_json::Value = serde_json::from_str(
2614 &read(&directory.path().join(".gpui-rhai/editor/components.json")).unwrap(),
2615 )
2616 .unwrap();
2617 let components = metadata["components"].as_array().unwrap();
2618 let installed = components
2619 .iter()
2620 .map(|component| component["metadata"]["id"].as_str().unwrap())
2621 .collect::<BTreeSet<_>>();
2622 assert_eq!(
2623 installed,
2624 BTreeSet::from([
2625 "components/button",
2626 "components/combobox",
2627 "components/input",
2628 ])
2629 );
2630 assert!(components.iter().any(|component| {
2631 component["metadata"]["id"] == "components/combobox"
2632 && component["schema"]["parts"]
2633 .as_array()
2634 .is_some_and(|parts| parts.iter().any(|part| part == "option"))
2635 }));
2636 let snippets = read(&directory.path().join(".gpui-rhai/editor/snippets.json")).unwrap();
2637 assert!(snippets.contains("import \\\"components/combobox\\\" as combobox;"));
2638 assert!(snippets.contains("${1:key}"));
2639 assert!(snippets.contains("label: ${2:label}"));
2640 assert!(snippets.contains("options: ${4:options}"));
2641 let definitions = read(
2642 &directory
2643 .path()
2644 .join(".gpui-rhai/editor/definitions/gpui_rhai.d.rhai"),
2645 )
2646 .unwrap();
2647 assert!(definitions.contains("fn render_component"));
2648 assert!(definitions.contains("fn canvas_fill_path"));
2649 }
2650}