1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::sync::OnceLock;
3
4use serde::Deserialize;
5
6use crate::core::error::{CatalogError, Result, WorkshopError};
7
8use super::{
9 CATALOG_DATA, Catalog, CatalogEntry, EnumDomain, EnumMember, Kind, Locale,
10 LocalizedStringEntry, NormalizedIndex, ParamCoercions, ParamContent, Provenance, TargetMeta,
11 normalize_spelling,
12};
13
14#[derive(Deserialize)]
15#[serde(rename_all = "camelCase")]
16struct CatalogFile {
17 schema_version: u32,
18 locales: Vec<String>,
19 target: TargetMeta,
20 provenance: Provenance,
21 #[serde(default)]
22 version: Option<String>,
23 #[serde(default)]
24 digest: Option<String>,
25 #[serde(default)]
26 structural: Vec<EntryFile>,
27 #[serde(default)]
28 actions: Vec<EntryFile>,
29 #[serde(default)]
30 values: Vec<EntryFile>,
31 #[serde(default)]
32 events: Vec<EntryFile>,
33 #[serde(default)]
34 operators: Vec<EntryFile>,
35 #[serde(default)]
36 settings: Vec<EntryFile>,
37 #[serde(default)]
38 localized_strings: Vec<LocalizedStringFile>,
39 #[serde(default)]
40 enums: Vec<EnumFile>,
41 #[serde(default)]
48 param_display_names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
49 param_display_name_gaps: Option<ParamDisplayNameGapsFile>,
57}
58
59#[derive(Deserialize, Default)]
62#[serde(rename_all = "camelCase")]
63struct ParamDisplayNameGapsFile {
64 #[serde(default)]
66 locales: Vec<String>,
67 #[serde(default)]
72 positions: Vec<String>,
73}
74
75#[derive(Deserialize)]
76#[serde(rename_all = "camelCase")]
77struct EntryFile {
78 id: String,
79 aliases: HashMap<String, AliasFile>,
80 #[serde(default)]
84 params: Option<Vec<String>>,
85 #[serde(default)]
87 param_names: Vec<String>,
88 #[serde(default)]
89 param_aliases: Vec<HashMap<String, AliasFile>>,
90 #[serde(default)]
93 param_domains: Vec<Option<String>>,
94 #[serde(default)]
101 param_defaults: Vec<Option<String>>,
102 #[serde(default)]
103 param_types: Vec<Option<String>>,
104 #[serde(default)]
105 param_coercions: Vec<Option<ParamCoercions>>,
106 #[serde(default)]
110 param_content: Vec<Option<ParamContent>>,
111 #[serde(default)]
112 return_type: Option<String>,
113 #[serde(default)]
119 reevaluation_coverage: Option<BTreeMap<String, Vec<usize>>>,
120 #[serde(default)]
121 variadic: bool,
122}
123
124#[derive(Deserialize)]
125struct LocalizedStringFile {
126 id: String,
127 aliases: HashMap<String, AliasFile>,
128}
129
130#[derive(Deserialize)]
131struct EnumFile {
132 domain: String,
133 #[serde(default)]
134 aliases: HashMap<String, AliasFile>,
135 members: Vec<MemberFile>,
136}
137
138#[derive(Deserialize)]
139struct MemberFile {
140 id: String,
141 aliases: HashMap<String, AliasFile>,
142}
143
144#[derive(Debug, Deserialize)]
149#[serde(untagged)]
150enum AliasFile {
151 One(String),
152 Many(Vec<String>),
153}
154
155impl AliasFile {
156 fn into_spellings(self, id: &str, locale: &str) -> Result<Vec<String>> {
157 let spellings = match self {
158 AliasFile::One(spelling) => vec![spelling],
159 AliasFile::Many(spellings) => spellings,
160 };
161 if spellings.is_empty() || spellings.iter().any(String::is_empty) {
162 return Err(CatalogError::validation(format!(
163 "catalog entry '{id}' declares an empty alias for locale '{locale}'"
164 )));
165 }
166 Ok(spellings)
167 }
168}
169impl Catalog {
170 pub fn load(json: &str) -> Result<Catalog> {
173 let catalog = Self::load_unverified(json)?;
174 if let Some(declared) = &catalog.catalog_digest {
175 let computed = content_digest(json)?;
176 if declared != &computed {
177 return Err(CatalogError::validation(format!(
178 "catalog digest mismatch: declared '{declared}', content '{computed}' — \
179 run the catalog pipeline (workshop-catalog-gen build)"
180 )));
181 }
182 }
183 Ok(catalog)
184 }
185
186 pub fn load_unverified(json: &str) -> Result<Catalog> {
189 let file: CatalogFile = serde_json::from_str(json)
190 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
191 if file.schema_version != 1 {
192 return Err(CatalogError::malformed(format!(
193 "unsupported catalog schemaVersion {}",
194 file.schema_version
195 )));
196 }
197 let locales: Vec<Locale> = file.locales.iter().map(|s| Locale::new(s)).collect();
198 if locales.is_empty() {
199 return Err(CatalogError::malformed(
200 "catalog declares no locales".to_string(),
201 ));
202 }
203
204 let mut catalog = Catalog {
205 detection_index: None,
206 schema_version: file.schema_version,
207 locales,
208 target: file.target,
209 provenance: file.provenance,
210 catalog_version: file.version.unwrap_or_else(|| "dev".to_string()),
211 catalog_digest: file.digest,
212 entries: Vec::new(),
213 localized_strings: Vec::new(),
214 enums: Vec::new(),
215 by_id: Default::default(),
216 alias_to_entry: HashMap::new(),
217 localized_string_by_id: HashMap::new(),
218 localized_string_alias: HashMap::new(),
219 enum_by_domain: HashMap::new(),
220 enum_alias_to_domain: HashMap::new(),
221 enum_alias_to_member: HashMap::new(),
222 bare_member_index: HashMap::new(),
223 normalized: NormalizedIndex::default(),
224 descriptions: Default::default(),
225 };
226
227 for (kind, items) in [
228 (Kind::Structural, file.structural),
229 (Kind::Action, file.actions),
230 (Kind::Value, file.values),
231 (Kind::Event, file.events),
232 (Kind::Operator, file.operators),
233 (Kind::Setting, file.settings),
234 ] {
235 for item in items {
236 catalog.insert_entry(kind, item)?;
237 }
238 }
239 for item in file.localized_strings {
240 catalog.insert_localized_string(item)?;
241 }
242 for domain in file.enums {
243 catalog.insert_enum(domain)?;
244 }
245 catalog
246 .insert_param_display_names(file.param_display_names, file.param_display_name_gaps)?;
247 for domain in &catalog.enums {
248 for member in &domain.members {
249 for (locale, spellings) in &member.aliases {
250 for spelling in spellings {
251 catalog
252 .bare_member_index
253 .entry(locale.clone())
254 .or_default()
255 .entry(spelling.clone())
256 .or_default()
257 .push((domain.domain.clone(), member.member.clone()));
258 }
259 }
260 }
261 }
262 catalog.build_normalized_index();
263 catalog.validate_param_domains()?;
264 catalog.validate_param_content()?;
265 catalog.detection_index =
266 Some(super::detect::AliasIndex::build(&catalog).map_err(|error| {
267 CatalogError::validation(format!("locale detection index: {error}"))
268 })?);
269 Ok(catalog)
270 }
271
272 pub fn builtin() -> Result<Catalog> {
274 Self::builtin_ref().cloned().map_err(Clone::clone)
275 }
276
277 pub fn builtin_ref() -> std::result::Result<&'static Catalog, &'static WorkshopError> {
281 static DATA: OnceLock<Result<Catalog>> = OnceLock::new();
282 match DATA.get_or_init(|| {
283 let mut catalog = Self::load(CATALOG_DATA)?;
284 catalog.load_descriptions(super::descriptions::DESCRIPTIONS_DATA)?;
285 Ok(catalog)
286 }) {
287 Ok(catalog) => Ok(catalog),
288 Err(error) => Err(error),
289 }
290 }
291 fn build_normalized_index(&mut self) {
297 fn insert<T: PartialEq + Clone>(
298 map: &mut HashMap<String, Option<T>>,
299 key: String,
300 value: T,
301 ) {
302 match map.get_mut(&key) {
303 Some(slot) if *slot != Some(value.clone()) => *slot = None,
304 Some(_) => {}
305 None => {
306 map.insert(key, Some(value));
307 }
308 }
309 }
310
311 let mut normalized = NormalizedIndex::default();
312 for (locale, by_kind) in &self.alias_to_entry {
313 let by_kind_norm = normalized
314 .alias_to_entry
315 .entry(locale.clone())
316 .or_insert_with(|| std::array::from_fn(|_| HashMap::new()));
317 for (kind_index, map) in by_kind.iter().enumerate() {
318 for (spelling, &entry_index) in map {
319 let key = normalize_spelling(spelling);
320 if key != spelling.as_str() {
321 insert(&mut by_kind_norm[kind_index], key.into_owned(), entry_index);
322 }
323 }
324 }
325 }
326 for (locale, map) in &self.localized_string_alias {
327 let norm = normalized
328 .localized_string_alias
329 .entry(locale.clone())
330 .or_default();
331 for (spelling, &string_index) in map {
332 let key = normalize_spelling(spelling);
333 if key != spelling.as_str() {
334 insert(norm, key.into_owned(), string_index);
335 }
336 }
337 }
338 for (locale, map) in &self.enum_alias_to_domain {
339 let norm = normalized
340 .enum_alias_to_domain
341 .entry(locale.clone())
342 .or_default();
343 for (spelling, domain) in map {
344 let key = normalize_spelling(spelling);
345 if key != spelling.as_str() {
346 insert(norm, key.into_owned(), domain.clone());
347 }
348 }
349 }
350 for (locale, domains) in &self.enum_alias_to_member {
351 let norm = normalized
352 .enum_alias_to_member
353 .entry(locale.clone())
354 .or_default();
355 for (domain, map) in domains {
356 let norm_map = norm.entry(domain.clone()).or_default();
357 for (spelling, &indexes) in map {
358 let key = normalize_spelling(spelling);
359 if key != spelling.as_str() {
360 insert(norm_map, key.into_owned(), indexes);
361 }
362 }
363 }
364 }
365 for (locale, map) in &self.bare_member_index {
366 let norm = normalized.bare_member.entry(locale.clone()).or_default();
367 for (spelling, matches) in map {
368 let key = normalize_spelling(spelling);
369 if key != spelling.as_str() {
370 let merged = norm.entry(key.into_owned()).or_default();
371 for pair in matches {
372 if !merged.contains(pair) {
373 merged.push(pair.clone());
374 }
375 }
376 }
377 }
378 }
379 self.normalized = normalized;
380 }
381
382 fn insert_entry(&mut self, kind: Kind, item: EntryFile) -> Result<()> {
383 let index = self.entries.len();
384 let aliases = self.collect_aliases(
385 &item.id,
386 &format!("entry '{}'", item.id),
387 item.aliases,
388 true,
389 |catalog, locale, spelling| {
390 let locale_map = catalog.alias_to_entry.entry(locale.clone()).or_default();
391 if locale_map[kind.as_index()].contains_key(spelling) {
392 return Err(CatalogError::validation(format!(
393 "duplicate {} alias '{spelling}' for locale '{locale}'",
394 kind.as_str()
395 )));
396 }
397 locale_map[kind.as_index()].insert(spelling.to_string(), index);
398 Ok(())
399 },
400 )?;
401 if self.by_id[kind.as_index()].contains_key(&item.id) {
402 return Err(CatalogError::validation(format!(
403 "duplicate {} id '{}'",
404 kind.as_str(),
405 item.id
406 )));
407 }
408 let callable = matches!(kind, Kind::Action | Kind::Value)
417 || (kind == Kind::Structural && item.params.is_some());
418 let params = item.params.unwrap_or_default();
419 let param_names = match item.param_names {
420 names if names.is_empty() => None,
421 names if names.len() != params.len() => {
422 return Err(CatalogError::validation(format!(
423 "{} '{}' declares {} param names for {} params",
424 kind.as_str(),
425 item.id,
426 names.len(),
427 params.len()
428 )));
429 }
430 names if names == params => None,
431 names => Some(names),
432 };
433 self.by_id[kind.as_index()].insert(item.id.clone(), index);
434 let item_id = item.id.clone();
435 self.entries.push(CatalogEntry {
436 id: item.id,
437 kind,
438 callable,
439 params,
440 param_names,
441 param_aliases: item
442 .param_aliases
443 .into_iter()
444 .map(|aliases| {
445 aliases
446 .into_iter()
447 .map(|(locale, alias)| {
448 let locale_key = Locale::new(&locale);
449 let spellings = alias.into_spellings(&item_id, locale_key.as_str())?;
450 Ok((locale_key, spellings))
451 })
452 .collect::<Result<HashMap<_, _>>>()
453 })
454 .collect::<Result<Vec<_>>>()?,
455 param_domains: item.param_domains,
456 param_display_names: Vec::new(),
457 param_defaults: item.param_defaults,
458 param_types: item.param_types,
459 param_coercions: item.param_coercions,
460 param_content: item.param_content,
461 return_type: item.return_type,
462 reevaluation_coverage: item.reevaluation_coverage,
463 variadic: item.variadic,
464 aliases,
465 });
466 Ok(())
467 }
468
469 fn insert_localized_string(&mut self, item: LocalizedStringFile) -> Result<()> {
470 if self.localized_string_by_id.contains_key(&item.id) {
471 return Err(CatalogError::validation(format!(
472 "duplicate localized string id '{}'",
473 item.id
474 )));
475 }
476 let index = self.localized_strings.len();
477 let aliases = self.collect_aliases(
478 &item.id,
479 &format!("localized string '{}'", item.id),
480 item.aliases,
481 true,
482 |catalog, locale, spelling| {
483 let locale_map = catalog
484 .localized_string_alias
485 .entry(locale.clone())
486 .or_default();
487 if locale_map.contains_key(spelling) {
488 return Err(CatalogError::validation(format!(
489 "duplicate localized string alias '{spelling}' for locale '{locale}'"
490 )));
491 }
492 locale_map.insert(spelling.to_string(), index);
493 Ok(())
494 },
495 )?;
496 self.localized_string_by_id.insert(item.id.clone(), index);
497 self.localized_strings.push(LocalizedStringEntry {
498 id: item.id,
499 aliases,
500 });
501 Ok(())
502 }
503
504 fn validate_param_domains(&self) -> Result<()> {
505 for entry in &self.entries {
506 for (name, count) in [
507 ("parameter alias sets", entry.param_aliases.len()),
508 ("param domains", entry.param_domains.len()),
509 ("param defaults", entry.param_defaults.len()),
510 ("param types", entry.param_types.len()),
511 ("param coercions", entry.param_coercions.len()),
512 ("param content facts", entry.param_content.len()),
513 ] {
514 if count > entry.params.len() {
515 return Err(CatalogError::validation(format!(
516 "{} '{}' declares more {name} than params",
517 entry.kind.as_str(),
518 entry.id
519 )));
520 }
521 }
522 if entry.kind != Kind::Value && entry.return_type.is_some() {
523 return Err(CatalogError::validation(format!(
524 "{} '{}' declares a return type but is not a value",
525 entry.kind.as_str(),
526 entry.id
527 )));
528 }
529 for domain in entry.param_domains.iter().flatten() {
530 if !self.enum_by_domain.contains_key(domain) {
531 return Err(CatalogError::validation(format!(
532 "{} '{}' declares undeclared enum domain '{domain}'",
533 entry.kind.as_str(),
534 entry.id
535 )));
536 }
537 }
538 if let Some(coverage) = &entry.reevaluation_coverage {
539 let reeval_params: Vec<usize> = (0..entry.params.len())
540 .filter(|index| {
541 entry
542 .param_domains
543 .get(*index)
544 .and_then(Option::as_deref)
545 .is_some_and(|domain| domain.ends_with("Reeval"))
546 })
547 .collect();
548 if reeval_params.len() != 1 {
549 return Err(CatalogError::validation(format!(
550 "{} '{}' declares reevaluation coverage but has {} *Reeval parameters",
551 entry.kind.as_str(),
552 entry.id,
553 reeval_params.len()
554 )));
555 }
556 let domain_name = entry.param_domains[reeval_params[0]]
557 .as_deref()
558 .expect("filtered above");
559 let domain = self
560 .enum_by_domain
561 .get(domain_name)
562 .map(|index| &self.enums[*index])
563 .expect("param domain validated above");
564 for (member, positions) in coverage {
565 if !domain.members.iter().any(|m| m.member == *member) {
566 return Err(CatalogError::validation(format!(
567 "{} '{}' declares reevaluation coverage for '{member}', which is not a member of '{domain_name}'",
568 entry.kind.as_str(),
569 entry.id
570 )));
571 }
572 for position in positions {
573 if *position >= entry.params.len() {
574 return Err(CatalogError::validation(format!(
575 "{} '{}' reevaluation coverage for '{member}' names parameter {position}, out of {} params",
576 entry.kind.as_str(),
577 entry.id,
578 entry.params.len()
579 )));
580 }
581 if *position == reeval_params[0] {
582 return Err(CatalogError::validation(format!(
583 "{} '{}' reevaluation coverage for '{member}' covers the reevaluation parameter itself",
584 entry.kind.as_str(),
585 entry.id
586 )));
587 }
588 }
589 }
590 for member in &domain.members {
591 if !coverage.contains_key(&member.member) {
592 return Err(CatalogError::validation(format!(
593 "{} '{}' reevaluation coverage does not review '{domain_name}' member '{}'",
594 entry.kind.as_str(),
595 entry.id,
596 member.member
597 )));
598 }
599 }
600 }
601 for aliases in &entry.param_aliases {
602 for locale in aliases.keys() {
603 if !self.locales.contains(locale) {
604 return Err(CatalogError::validation(format!(
605 "{} '{}' declares parameter alias for undeclared locale '{}'",
606 entry.kind.as_str(),
607 entry.id,
608 locale
609 )));
610 }
611 }
612 }
613 }
614 Ok(())
615 }
616
617 fn validate_param_content(&self) -> Result<()> {
625 for entry in &self.entries {
626 let mut groups: BTreeMap<&str, Vec<(usize, usize)>> = BTreeMap::new();
628 for (index, content) in entry.param_content.iter().enumerate() {
629 let Some(content) = content else {
630 continue;
631 };
632 if content.fold.is_none() && content.operations.is_empty() {
633 return Err(CatalogError::validation(format!(
634 "{} '{}' parameter {} declares an empty paramContent fact",
635 entry.kind.as_str(),
636 entry.id,
637 index + 1
638 )));
639 }
640 for operation in &content.operations {
641 if !self.by_id[Kind::Operator.as_index()].contains_key(operation) {
642 return Err(CatalogError::validation(format!(
643 "{} '{}' parameter {} accepts undeclared operator '{operation}'",
644 entry.kind.as_str(),
645 entry.id,
646 index + 1
647 )));
648 }
649 }
650 let Some(fold) = &content.fold else {
651 continue;
652 };
653 let (arity, variable_arg) = match fold.call.as_str() {
657 "globalVariable" => (1, 0),
658 "playerVariable" => (2, 1),
659 _ => {
660 return Err(CatalogError::validation(format!(
661 "{} '{}' parameter {} declares a fold into unknown \
662 call '{}'",
663 entry.kind.as_str(),
664 entry.id,
665 index + 1,
666 fold.call
667 )));
668 }
669 };
670 if fold.arg >= arity {
671 return Err(CatalogError::validation(format!(
672 "{} '{}' parameter {} declares a fold into '{}' \
673 argument {}, out of {}",
674 entry.kind.as_str(),
675 entry.id,
676 index + 1,
677 fold.call,
678 fold.arg,
679 arity
680 )));
681 }
682 let variable_typed = matches!(
683 entry.param_type(index),
684 Some("Variable") | Some("Global Variable")
685 );
686 if fold.arg == variable_arg && !variable_typed {
687 return Err(CatalogError::validation(format!(
688 "{} '{}' parameter {} supplies '{}' argument {}, the tagged \
689 variable node, but is not Variable-typed",
690 entry.kind.as_str(),
691 entry.id,
692 index + 1,
693 fold.call,
694 fold.arg
695 )));
696 }
697 groups
698 .entry(fold.call.as_str())
699 .or_default()
700 .push((index, fold.arg));
701 }
702 for (call, members) in groups {
703 let arity = if call == "globalVariable" { 1 } else { 2 };
704 let positions: Vec<usize> = members.iter().map(|(index, _)| *index).collect();
705 let contiguous = positions.windows(2).all(|pair| pair[1] == pair[0] + 1);
706 let mut args: Vec<usize> = members.iter().map(|(_, arg)| *arg).collect();
707 args.sort_unstable();
708 if !contiguous || args != (0..arity).collect::<Vec<_>>() {
709 return Err(CatalogError::validation(format!(
710 "{} '{}' declares an incomplete '{}' fold group: members must be \
711 contiguous parameters covering all {} argument(s)",
712 entry.kind.as_str(),
713 entry.id,
714 call,
715 arity
716 )));
717 }
718 }
719 }
720 Ok(())
721 }
722
723 fn insert_enum(&mut self, domain: EnumFile) -> Result<()> {
724 let domain_index = self.enums.len();
725 if self.enum_by_domain.contains_key(&domain.domain) {
726 return Err(CatalogError::validation(format!(
727 "duplicate enum domain '{}'",
728 domain.domain
729 )));
730 }
731 let primary = self.locales[0].clone();
732 let mut domain_aliases = self.collect_aliases(
733 &domain.domain,
734 &format!("enum domain '{}'", domain.domain),
735 domain.aliases,
736 false,
737 |catalog, locale, spelling| {
738 let locale_map = catalog
739 .enum_alias_to_domain
740 .entry(locale.clone())
741 .or_default();
742 if let Some(existing) = locale_map.get(spelling) {
743 return Err(CatalogError::validation(format!(
744 "duplicate enum domain alias '{spelling}' for '{existing}' and '{}' in locale '{locale}'",
745 domain.domain
746 )));
747 }
748 locale_map.insert(spelling.to_string(), domain.domain.clone());
749 Ok(())
750 },
751 )?;
752 domain_aliases
753 .entry(primary.clone())
754 .or_insert_with(|| vec![domain.domain.clone()]);
755 self.enum_alias_to_domain
756 .entry(primary.clone())
757 .or_default()
758 .entry(domain.domain.clone())
759 .or_insert_with(|| domain.domain.clone());
760 let mut members = Vec::new();
761 for (member_index, member) in domain.members.into_iter().enumerate() {
762 let aliases = self.collect_aliases(
763 &member.id,
764 &format!("enum {}::{}", domain.domain, member.id),
765 member.aliases,
766 true,
767 |catalog, locale, spelling| {
768 let domain_map = catalog
769 .enum_alias_to_member
770 .entry(locale.clone())
771 .or_default()
772 .entry(domain.domain.clone())
773 .or_default();
774 if domain_map.contains_key(spelling) {
775 return Err(CatalogError::validation(format!(
776 "duplicate enum alias '{spelling}' in '{}' for locale '{locale}'",
777 domain.domain
778 )));
779 }
780 domain_map.insert(spelling.to_string(), (domain_index, member_index));
781 Ok(())
782 },
783 )?;
784 members.push(EnumMember {
785 member: member.id,
786 aliases,
787 });
788 }
789 self.enum_by_domain
790 .insert(domain.domain.clone(), domain_index);
791 self.enums.push(EnumDomain {
792 domain: domain.domain,
793 aliases: domain_aliases,
794 members,
795 });
796 Ok(())
797 }
798
799 fn insert_param_display_names(
807 &mut self,
808 names: BTreeMap<String, BTreeMap<String, BTreeMap<String, String>>>,
809 gaps: Option<ParamDisplayNameGapsFile>,
810 ) -> Result<()> {
811 let mut gap_locales = BTreeSet::new();
812 let mut gap_positions = BTreeSet::new();
813 if let Some(gaps) = &gaps {
814 for gap_locale in &gaps.locales {
815 let locale = Locale::new(gap_locale);
816 if !self.locales.contains(&locale) {
817 return Err(CatalogError::validation(format!(
818 "param display name gap locale '{locale}' is not a declared locale"
819 )));
820 }
821 if !gap_locales.insert(locale) {
822 return Err(CatalogError::validation(format!(
823 "duplicate param display name gap locale '{gap_locale}'"
824 )));
825 }
826 }
827 for position in &gaps.positions {
828 let (id, index) = position
829 .strip_suffix(']')
830 .and_then(|text| text.split_once('['))
831 .ok_or_else(|| {
832 CatalogError::validation(format!(
833 "param display name gap '{position}' is not written 'entryId[index]'"
834 ))
835 })?;
836 let index: usize = index.parse().map_err(|_| {
837 CatalogError::validation(format!(
838 "param display name gap '{position}' has a non-numeric index"
839 ))
840 })?;
841 let param_counts = self
842 .by_id
843 .iter()
844 .filter_map(|by_id| by_id.get(id))
845 .map(|&entry_index| self.entries[entry_index].params.len())
846 .collect::<Vec<_>>();
847 if param_counts.is_empty() {
848 return Err(CatalogError::validation(format!(
849 "param display name gap '{position}' names undeclared entry '{id}'"
850 )));
851 }
852 if !param_counts.iter().any(|&len| index < len) {
853 return Err(CatalogError::validation(format!(
854 "param display name gap '{position}' exceeds the parameters of '{id}'"
855 )));
856 }
857 if !gap_positions.insert((id.to_string(), index)) {
858 return Err(CatalogError::validation(format!(
859 "duplicate param display name gap '{position}'"
860 )));
861 }
862 }
863 }
864
865 for (id, positions) in names {
866 let entry_indices = self
871 .by_id
872 .iter()
873 .filter_map(|by_id| by_id.get(&id))
874 .copied()
875 .collect::<Vec<_>>();
876 if entry_indices.is_empty() {
877 return Err(CatalogError::validation(format!(
878 "param display names name undeclared entry '{id}'"
879 )));
880 }
881 for (position, map) in positions {
882 let index: usize = position.parse().map_err(|_| {
883 CatalogError::validation(format!(
884 "'{id}' param display name position '{position}' is not a zero-based index"
885 ))
886 })?;
887 let mut recorded = HashMap::new();
888 for (name_locale, name) in map {
889 let locale = Locale::new(&name_locale);
890 if !self.locales.contains(&locale) {
891 return Err(CatalogError::validation(format!(
892 "'{id}' declares a parameter display name for undeclared locale '{locale}'"
893 )));
894 }
895 if gap_locales.contains(&locale) {
896 return Err(CatalogError::validation(format!(
897 "'{id}' declares a parameter display name for gap locale '{locale}'"
898 )));
899 }
900 if name.is_empty() {
901 return Err(CatalogError::validation(format!(
902 "'{id}' declares an empty parameter display name for locale '{locale}'"
903 )));
904 }
905 recorded.insert(locale, name);
906 }
907 if recorded.is_empty() {
908 return Err(CatalogError::validation(format!(
909 "'{id}' declares no display names for parameter {index}"
910 )));
911 }
912 let mut applied = false;
916 for &entry_index in &entry_indices {
917 let entry = &mut self.entries[entry_index];
918 if index >= entry.params.len() {
919 continue;
920 }
921 entry.param_display_names.resize(entry.params.len(), None);
922 entry.param_display_names[index] = Some(recorded.clone());
923 applied = true;
924 }
925 if !applied {
926 return Err(CatalogError::validation(format!(
927 "'{id}' declares a display name for parameter {index}, which no entry of that id has"
928 )));
929 }
930 }
931 }
932
933 if gaps.is_some() {
934 let supported = self
935 .locales
936 .iter()
937 .filter(|locale| !gap_locales.contains(*locale))
938 .collect::<Vec<_>>();
939 for entry in &self.entries {
940 for index in 0..entry.params.len() {
941 let names = entry
942 .param_display_names
943 .get(index)
944 .and_then(Option::as_ref);
945 let complete = supported
946 .iter()
947 .all(|locale| names.is_some_and(|map| map.contains_key(*locale)));
948 let gapped = gap_positions.contains(&(entry.id.clone(), index));
949 if complete == gapped {
950 return Err(CatalogError::validation(format!(
951 "{} '{}' parameter {index} must be listed in paramDisplayNameGaps exactly when it lacks a display name in a supported locale",
952 entry.kind.as_str(),
953 entry.id
954 )));
955 }
956 }
957 }
958 }
959 Ok(())
960 }
961
962 fn collect_aliases(
963 &mut self,
964 id: &str,
965 label: &str,
966 aliases: HashMap<String, AliasFile>,
967 require_primary: bool,
968 mut register: impl FnMut(&mut Self, &Locale, &str) -> Result<()>,
969 ) -> Result<HashMap<Locale, Vec<String>>> {
970 let mut collected = HashMap::new();
971 for (locale_str, alias_file) in aliases {
972 let locale = Locale::new(&locale_str);
973 if !self.locales.contains(&locale) {
974 return Err(CatalogError::validation(format!(
975 "{label} declares alias for undeclared locale '{locale}'"
976 )));
977 }
978 let spellings = alias_file.into_spellings(id, locale.as_str())?;
979 for spelling in &spellings {
980 register(self, &locale, spelling)?;
981 }
982 collected.insert(locale, spellings);
983 }
984 let primary = &self.locales[0];
985 if require_primary && !collected.contains_key(primary) {
986 return Err(CatalogError::validation(format!(
987 "{label} is missing a '{primary}' alias"
988 )));
989 }
990 Ok(collected)
991 }
992}
993
994pub fn canonicalize(json: &str) -> Result<String> {
1000 Catalog::load_unverified(json)?;
1001 let value: serde_json::Value = serde_json::from_str(json)
1002 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1003 pretty_json(&value)
1004}
1005
1006pub fn build_canonical(json: &str) -> Result<String> {
1010 let mut value: serde_json::Value = serde_json::from_str(json)
1011 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1012 let digest = content_digest(json)?;
1013 if let Some(object) = value.as_object_mut() {
1014 object.insert("digest".to_string(), serde_json::Value::String(digest));
1015 }
1016 let output = pretty_json(&value)?;
1017 Catalog::load(&output)?;
1018 Ok(output)
1019}
1020
1021pub fn content_digest(json: &str) -> Result<String> {
1026 let mut value: serde_json::Value = serde_json::from_str(json)
1027 .map_err(|error| CatalogError::malformed(format!("catalog data: {error}")))?;
1028 if let Some(object) = value.as_object_mut() {
1029 object.remove("digest");
1030 }
1031 let canonical = pretty_json(&value)?;
1032 use sha2::{Digest, Sha256};
1033 let mut hasher = Sha256::new();
1034 hasher.update(canonical.trim_end().as_bytes());
1035 Ok(format!("{:x}", hasher.finalize()))
1036}
1037
1038pub(crate) fn pretty_json(value: &serde_json::Value) -> Result<String> {
1039 serde_json::to_string_pretty(value)
1040 .map(|mut out| {
1041 out.push('\n');
1042 out
1043 })
1044 .map_err(|error| CatalogError::malformed(format!("cannot serialize catalog: {error}")))
1045}