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