1use anyhow::{Result, bail};
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex, OnceLock};
13
14use crate::{TypeDescriptor, Value};
15
16pub const MAX_GENERIC_DEPTH: usize = 8;
21
22#[macro_export]
30macro_rules! union_tag {
31 ($($member:ident),+ $(,)?) => {
32 $crate::TypeTag::union(vec![$($crate::TypeTag::$member),+])
33 };
34}
35
36#[derive(Debug, Clone)]
38pub struct Field {
39 pub name: String,
43 pub ty: TypeTag,
45 pub docs: &'static str,
48 pub optional: bool,
52}
53
54#[derive(Clone)]
66pub enum TypeTag {
67 String,
68 Int,
69 Float,
70 Bool,
71 List,
72 Map,
73 Pipe,
74 Handle,
75 Duration,
76 Path,
77 Semaphore,
78 Permit,
79 Any,
83 Custom(&'static TypeDescriptor),
84 ListOf(Arc<TypeTag>),
85 Record(Arc<[Field]>),
86 MapOf(Arc<TypeTag>),
87 Union(Arc<str>, Arc<[TypeTag]>),
95}
96
97pub const ANY_TAG: TypeTag = TypeTag::Any;
100
101pub const MAP_TAG: TypeTag = TypeTag::Map;
104
105pub const LIST_TAG: TypeTag = TypeTag::List;
107impl std::fmt::Debug for TypeTag {
108 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
109 match self {
110 TypeTag::Custom(descriptor) => {
111 write!(f, "Custom({})", descriptor.name)
112 }
113 TypeTag::ListOf(element) => write!(f, "ListOf({element:?})"),
114 TypeTag::MapOf(values) => write!(f, "MapOf({values:?})"),
115 TypeTag::Record(fields) => {
116 let names: Vec<&str> = fields.iter().map(|field| field.name.as_str()).collect();
117 write!(f, "Record({})", names.join(", "))
118 }
119 TypeTag::Union(spelling, _) => write!(f, "Union({spelling})"),
124 tag => write!(f, "{}", tag.name()),
125 }
126 }
127}
128
129impl std::fmt::Display for TypeTag {
130 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131 f.write_str(self.name())
132 }
133}
134impl TypeTag {
135 pub fn union(members: Vec<TypeTag>) -> TypeTag {
142 let spelling: Arc<str> = members
143 .iter()
144 .map(render_structural)
145 .collect::<Vec<_>>()
146 .join("|")
147 .into();
148 TypeTag::Union(spelling, Arc::from(members))
149 }
150
151 pub fn name(&self) -> &str {
156 match self {
157 TypeTag::String => "STRING",
158 TypeTag::Int => "INT",
159 TypeTag::Float => "FLOAT",
160 TypeTag::Bool => "BOOL",
161 TypeTag::List => "LIST",
162 TypeTag::Map => "MAP",
163 TypeTag::Pipe => "PIPE",
164 TypeTag::Handle => "HANDLE",
165 TypeTag::Duration => "DURATION",
166 TypeTag::Path => "PATH",
167 TypeTag::Semaphore => "SEMAPHORE",
168 TypeTag::Permit => "PERMIT",
169 TypeTag::Any => "ANY",
170 TypeTag::Union(spelling, _) => spelling,
173 TypeTag::Custom(descriptor) => descriptor.name,
174 TypeTag::ListOf(_) => "LIST",
175 TypeTag::MapOf(_) => "MAP",
176 TypeTag::Record(_) => "MAP",
177 }
178 }
179
180 pub fn builtin(name: &str) -> Option<TypeTag> {
184 match name {
185 "STRING" => Some(TypeTag::String),
186 "INT" => Some(TypeTag::Int),
187 "FLOAT" => Some(TypeTag::Float),
188 "BOOL" => Some(TypeTag::Bool),
189 "LIST" => Some(TypeTag::List),
190 "MAP" => Some(TypeTag::Map),
191 "PIPE" => Some(TypeTag::Pipe),
192 "HANDLE" => Some(TypeTag::Handle),
193 "DURATION" => Some(TypeTag::Duration),
194 "PATH" => Some(TypeTag::Path),
195 "SEMAPHORE" => Some(TypeTag::Semaphore),
196 "PERMIT" => Some(TypeTag::Permit),
197 "ANY" => Some(TypeTag::Any),
198 _ => None,
199 }
200 }
201
202 pub fn fields(&self) -> Option<&[Field]> {
207 match self {
208 TypeTag::Record(fields) => Some(fields),
209 TypeTag::String
210 | TypeTag::Int
211 | TypeTag::Float
212 | TypeTag::Bool
213 | TypeTag::List
214 | TypeTag::Map
215 | TypeTag::Pipe
216 | TypeTag::Handle
217 | TypeTag::Duration
218 | TypeTag::Path
219 | TypeTag::Semaphore
220 | TypeTag::Permit
221 | TypeTag::Any
222 | TypeTag::Custom(_)
223 | TypeTag::ListOf(_)
224 | TypeTag::MapOf(_)
225 | TypeTag::Union(..) => None,
226 }
227 }
228}
229
230pub fn render_structural(tag: &TypeTag) -> String {
235 match tag {
236 TypeTag::ListOf(inner) => format!("LIST<{}>", render_structural(inner)),
237 TypeTag::MapOf(values) => format!("MAP<{}>", render_structural(values)),
238 TypeTag::Record(fields) => {
239 let field_list = fields
240 .iter()
241 .map(|field| {
242 format!(
243 "{}{}: {}",
244 field.name,
245 if field.optional { "?" } else { "" },
246 render_structural(&field.ty)
247 )
248 })
249 .collect::<Vec<_>>()
250 .join(", ");
251 format!("MAP<{field_list}>")
252 }
253 tag => tag.name().to_string(),
254 }
255}
256
257#[derive(Debug, Clone, PartialEq, Eq)]
265pub enum Spelled {
266 Named(String),
267 Generic {
268 name: String,
269 args: Vec<SpelledField>,
270 },
271 Union(Vec<Spelled>),
272}
273
274#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct SpelledField {
278 pub name: Option<String>,
279 pub optional: bool,
280 pub ty: Spelled,
281}
282
283pub fn canonicalize_spelling(s: &str) -> String {
291 let stripped: String = s.chars().filter(|c| !c.is_whitespace()).collect();
292 match parse_spelling(&stripped) {
293 Ok(spelled) => render_spelled(&spelled),
294 Err(_) => s.trim().to_string(),
295 }
296}
297
298pub fn render_spelled(spelled: &Spelled) -> String {
302 match spelled {
303 Spelled::Named(name) => name.clone(),
304 Spelled::Union(members) => members
305 .iter()
306 .map(render_spelled)
307 .collect::<Vec<_>>()
308 .join("|"),
309 Spelled::Generic { name, args } => {
310 let rendered: Vec<String> = args
311 .iter()
312 .map(|field| match &field.name {
313 Some(field_name) => format!(
314 "{}{}: {}",
315 field_name,
316 if field.optional { "?" } else { "" },
317 render_spelled(&field.ty)
318 ),
319 None => render_spelled(&field.ty),
320 })
321 .collect();
322 format!("{name}<{}>", rendered.join(", "))
323 }
324 }
325}
326
327pub fn parse_spelling(s: &str) -> Result<Spelled, String> {
332 let text: String = s.chars().filter(|c| !c.is_whitespace()).collect();
333 let (spelled, rest) = parse_spelled_one(&text, 0)?;
334 if !rest.is_empty() {
335 return Err(format!(
336 "unexpected trailing text in type spelling: {rest:?}"
337 ));
338 }
339 Ok(spelled)
340}
341
342fn parse_spelled_one(text: &str, depth: usize) -> Result<(Spelled, &str), String> {
343 let (first, mut rest) = parse_spelled_shape(text, depth)?;
344 if !rest.starts_with('|') {
345 return Ok((first, rest));
346 }
347 let mut members = vec![first];
352 while let Some(tail) = rest.strip_prefix('|') {
353 let (next, after) = parse_spelled_shape(tail, depth)?;
354 members.push(next);
355 rest = after;
356 }
357 Ok((Spelled::Union(members), rest))
358}
359
360fn parse_spelled_shape(text: &str, depth: usize) -> Result<(Spelled, &str), String> {
361 if depth > MAX_GENERIC_DEPTH {
362 return Err(format!(
363 "type spelling exceeds maximum generic depth of {MAX_GENERIC_DEPTH}: {text:?}"
364 ));
365 }
366 let mut chars = text.char_indices();
367 let Some((_, first)) = chars.next() else {
368 return Err("expected type name, found end of spelling".to_string());
369 };
370 if !first.is_ascii_uppercase() {
371 return Err(format!("expected uppercase type name, found {first:?}"));
372 }
373 let mut end = first.len_utf8();
374 for (idx, ch) in chars {
375 if ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_' {
376 end = idx + ch.len_utf8();
377 } else {
378 break;
379 }
380 }
381 let (name, mut rest) = text.split_at(end);
382 let name = name.to_string();
383 if !rest.starts_with('<') {
384 return Ok((Spelled::Named(name), rest));
385 }
386 rest = &rest[1..];
387 let mut args = Vec::new();
388 loop {
389 let (field_name, optional, after_name) = parse_spelled_field_name(rest);
390 let (arg, after_arg) = parse_spelled_one(after_name, depth + 1)?;
391 args.push(SpelledField {
392 name: field_name,
393 optional,
394 ty: arg,
395 });
396 rest = after_arg;
397 if let Some(tail) = rest.strip_prefix(',') {
398 rest = tail;
399 continue;
400 }
401 if let Some(tail) = rest.strip_prefix('>') {
402 rest = tail;
403 break;
404 }
405 return Err(format!(
406 "expected ',' or '>' in type spelling, found {rest:?}"
407 ));
408 }
409 Ok((Spelled::Generic { name, args }, rest))
410}
411
412fn parse_spelled_field_name(text: &str) -> (Option<String>, bool, &str) {
417 let mut chars = text.char_indices();
418 let Some((_, first)) = chars.next() else {
419 return (None, false, text);
420 };
421 if !first.is_ascii_lowercase() {
422 return (None, false, text);
423 };
424 let mut end = first.len_utf8();
425 for (idx, ch) in chars {
426 if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' {
427 end = idx + ch.len_utf8();
428 } else {
429 break;
430 }
431 }
432 let (candidate, rest) = text.split_at(end);
433 let (optional, rest) = match rest.strip_prefix('?') {
434 Some(after) => (true, after),
435 None => (false, rest),
436 };
437 match rest.strip_prefix(':') {
438 Some(after) => (Some(candidate.to_string()), optional, after),
439 None => (None, false, text),
440 }
441}
442
443static COMPOSED_TAGS: OnceLock<Mutex<HashMap<String, TypeTag>>> = OnceLock::new();
448
449fn composed_table() -> &'static Mutex<HashMap<String, TypeTag>> {
450 COMPOSED_TAGS.get_or_init(|| Mutex::new(HashMap::new()))
451}
452
453pub fn lookup_composed(canonical: &str) -> Option<TypeTag> {
457 composed_table()
458 .lock()
459 .unwrap_or_else(|poisoned| poisoned.into_inner())
460 .get(canonical)
461 .cloned()
462}
463
464pub fn intern_composed(canonical: String, tag: TypeTag) -> TypeTag {
470 let mut table = composed_table()
471 .lock()
472 .unwrap_or_else(|poisoned| poisoned.into_inner());
473 if let Some(hit) = table.get(&canonical) {
474 return hit.clone();
475 }
476 table.insert(canonical, tag.clone());
477 tag
478}
479
480pub fn check_value(tag: &TypeTag, value: &Value) -> Result<()> {
484 check_value_at(tag, value, "$")
485}
486
487pub fn check_value_at(tag: &TypeTag, value: &Value, path: &str) -> Result<()> {
493 match tag {
494 TypeTag::Any => Ok(()),
495 TypeTag::String if value.as_str().is_some() => Ok(()),
496 TypeTag::Int if value.as_i64().is_some() => Ok(()),
497 TypeTag::Float if value.as_f64().is_some() => Ok(()),
498 TypeTag::Bool if value.as_bool().is_some() => Ok(()),
499 TypeTag::Pipe if value.as_pipe_handle().is_some() => Ok(()),
500 TypeTag::Handle if value.as_handle().is_some() => Ok(()),
501 TypeTag::Duration if value.as_duration().is_some() => Ok(()),
502 TypeTag::Path if value.as_path().is_some() => Ok(()),
503 TypeTag::Semaphore if value.as_semaphore().is_some() => Ok(()),
504 TypeTag::Permit if value.as_permit().is_some() => Ok(()),
505 TypeTag::List => match value.as_list() {
506 Some(_) => Ok(()),
507 None => bail!("{path}: expected LIST, got {}", value.type_name()),
508 },
509 TypeTag::Map => match value.as_map() {
510 Some(_) => Ok(()),
511 None => bail!("{path}: expected MAP, got {}", value.type_name()),
512 },
513 TypeTag::ListOf(element) => match value.as_list() {
514 Some(items) => {
515 for (idx, item) in items.iter().enumerate() {
516 check_value_at(element, item, &format!("{path}[{idx}]"))?;
517 }
518 Ok(())
519 }
520 None => bail!("{path}: expected LIST, got {}", value.type_name()),
521 },
522 TypeTag::MapOf(values) => match value.as_map() {
523 Some(map) => {
524 for (key, item) in map.iter() {
525 check_value_at(values, item, &format!("{path}.{key}"))?;
526 }
527 Ok(())
528 }
529 None => bail!(
530 "{path}: expected {}, got {}",
531 render_structural(tag),
532 value.type_name()
533 ),
534 },
535 TypeTag::Record(fields) => match value.as_map() {
536 Some(map) => {
537 for field in fields.iter() {
538 match map.get(field.name.as_str()) {
539 Some(item) => {
540 check_value_at(&field.ty, item, &format!("{path}.{}", field.name))?;
541 }
542 None if field.optional => {}
543 None => bail!(
544 "{path}: missing field '{}'; expected fields: {}",
545 field.name,
546 field_list(fields),
547 ),
548 }
549 }
550 for key in map.keys() {
551 if !fields.iter().any(|field| field.name == *key) {
552 bail!(
553 "{path}: unknown field '{key}'; expected fields: {}",
554 field_list(fields),
555 );
556 }
557 }
558 Ok(())
559 }
560 None => bail!("{path}: expected MAP, got {}", value.type_name()),
561 },
562 TypeTag::Custom(descriptor) => {
563 if value.type_name() == descriptor.name {
564 Ok(())
565 } else {
566 bail!(
567 "{path}: expected {}, got {}",
568 descriptor.name,
569 value.type_name(),
570 )
571 }
572 }
573 TypeTag::Union(_, members) => {
577 if members
578 .iter()
579 .any(|member| check_value(member, value).is_ok())
580 {
581 Ok(())
582 } else {
583 bail!(
584 "{path}: expected {}, got {}",
585 render_structural(tag),
586 value.type_name()
587 )
588 }
589 }
590 TypeTag::String
594 | TypeTag::Int
595 | TypeTag::Float
596 | TypeTag::Bool
597 | TypeTag::Pipe
598 | TypeTag::Handle
599 | TypeTag::Duration
600 | TypeTag::Path
601 | TypeTag::Semaphore
602 | TypeTag::Permit => bail!("{path}: expected {}, got {}", tag.name(), value.type_name()),
603 }
604}
605
606fn field_list(fields: &[Field]) -> String {
607 fields
608 .iter()
609 .map(|field| field.name.as_str())
610 .collect::<Vec<_>>()
611 .join(", ")
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617 use std::collections::BTreeMap;
618 use std::sync::Arc;
619
620 fn map(entries: &[(&str, Value)]) -> Value {
621 Value::map(
622 entries
623 .iter()
624 .map(|(k, v)| ((*k).to_string(), v.clone()))
625 .collect::<BTreeMap<_, _>>(),
626 )
627 }
628
629 #[test]
630 fn builtin_round_trips_through_names() {
631 for name in [
632 "STRING",
633 "INT",
634 "FLOAT",
635 "BOOL",
636 "LIST",
637 "MAP",
638 "PIPE",
639 "HANDLE",
640 "DURATION",
641 "PATH",
642 "SEMAPHORE",
643 "PERMIT",
644 ] {
645 let tag = TypeTag::builtin(name).expect("builtin");
646 assert_eq!(tag.name(), name);
647 }
648 assert!(TypeTag::builtin("NOPE").is_none());
649 assert!(TypeTag::builtin("string").is_none());
650 }
651
652 #[test]
653 fn scalars_conform_by_discriminant() {
654 assert!(check_value(&TypeTag::String, &Value::string("x".to_string())).is_ok());
655 assert!(check_value(&TypeTag::Int, &Value::int(1)).is_ok());
656 assert!(check_value(&TypeTag::String, &Value::int(1)).is_err());
657 assert!(check_value(&TypeTag::Int, &Value::float(3.0)).is_err());
659 }
660
661 #[test]
662 fn record_rejects_missing_and_extra_fields() {
663 let fields: Arc<[Field]> = Arc::from([
664 Field {
665 name: "a".to_string(),
666 ty: TypeTag::String,
667 docs: "",
668 optional: false,
669 },
670 Field {
671 name: "b".to_string(),
672 ty: TypeTag::Int,
673 docs: "",
674 optional: false,
675 },
676 ]);
677 let tag = TypeTag::Record(fields);
678 let good = map(&[("a", Value::string("x".to_string())), ("b", Value::int(1))]);
679 assert!(check_value(&tag, &good).is_ok());
680 let missing = map(&[("a", Value::string("x".to_string()))]);
681 let err = check_value(&tag, &missing).expect_err("missing must fail");
682 assert!(format!("{err:#}").contains('b'), "names the field: {err:#}");
683 let extra = map(&[
684 ("a", Value::string("x".to_string())),
685 ("b", Value::int(1)),
686 ("c", Value::int(2)),
687 ]);
688 let err = check_value(&tag, &extra).expect_err("extra must fail");
689 assert!(format!("{err:#}").contains('c'), "names the field: {err:#}");
690 let mistyped = map(&[
691 ("a", Value::string("x".to_string())),
692 ("b", Value::string("nope".to_string())),
693 ]);
694 assert!(check_value(&tag, &mistyped).is_err());
695 }
696
697 #[test]
698 fn record_optional_fields_may_be_absent() {
699 let fields: Arc<[Field]> = Arc::from([
702 Field {
703 name: "held".to_string(),
704 ty: TypeTag::Int,
705 docs: "",
706 optional: false,
707 },
708 Field {
709 name: "permit".to_string(),
710 ty: TypeTag::String,
711 docs: "",
712 optional: true,
713 },
714 ]);
715 let tag = TypeTag::Record(fields);
716 let full = map(&[
717 ("held", Value::int(1)),
718 ("permit", Value::string("p".to_string())),
719 ]);
720 assert!(check_value(&tag, &full).is_ok());
721 let missing_optional = map(&[("held", Value::int(0))]);
722 assert!(check_value(&tag, &missing_optional).is_ok());
723 let missing_required = map(&[("permit", Value::string("p".to_string()))]);
724 let err = check_value(&tag, &missing_required).expect_err("missing required must fail");
725 assert!(
726 format!("{err:#}").contains("held"),
727 "names the field: {err:#}"
728 );
729 let mistyped_optional = map(&[("held", Value::int(1)), ("permit", Value::int(2))]);
730 assert!(check_value(&tag, &mistyped_optional).is_err());
731 }
732
733 #[test]
734 fn list_of_checks_elements_with_index() {
735 let tag = TypeTag::ListOf(Arc::new(TypeTag::Int));
736 let good = Value::list(vec![Value::int(1), Value::int(2)]);
737 assert!(check_value(&tag, &good).is_ok());
738 let bad = Value::list(vec![Value::int(1), Value::string("x".to_string())]);
739 let err = check_value(&tag, &bad).expect_err("bad element must fail");
740 assert!(
741 format!("{err:#}").contains("[1]"),
742 "names the index: {err:#}"
743 );
744 }
745
746 #[test]
747 fn canonicalization_unifies_input_spacing() {
748 assert_eq!(canonicalize_spelling("LIST<MAP>"), "LIST<MAP>");
751 assert_eq!(canonicalize_spelling("LIST< MAP >"), "LIST<MAP>");
752 assert_eq!(
753 canonicalize_spelling("MAP<name:STRING,age:INT>"),
754 "MAP<name: STRING, age: INT>"
755 );
756 assert_eq!(
757 canonicalize_spelling("MAP<name : STRING>"),
758 "MAP<name: STRING>"
759 );
760 }
761
762 #[test]
763 fn spellings_parse_to_shapes() {
764 assert_eq!(
765 parse_spelling("LIST<MAP>").expect("parses"),
766 Spelled::Generic {
767 name: "LIST".to_string(),
768 args: vec![SpelledField {
769 name: None,
770 optional: false,
771 ty: Spelled::Named("MAP".to_string()),
772 }],
773 }
774 );
775 assert_eq!(
776 parse_spelling("MAP<name: STRING>").expect("parses"),
777 Spelled::Generic {
778 name: "MAP".to_string(),
779 args: vec![SpelledField {
780 name: Some("name".to_string()),
781 optional: false,
782 ty: Spelled::Named("STRING".to_string()),
783 }],
784 }
785 );
786 assert!(parse_spelling("LIST<A, B>").is_ok());
789 assert!(parse_spelling("MAP<STRING>").is_ok());
790 }
791
792 #[test]
793 fn optional_field_spelling_parses_and_renders() {
794 assert_eq!(
797 parse_spelling("MAP<permit?: PERMIT>").expect("parses"),
798 Spelled::Generic {
799 name: "MAP".to_string(),
800 args: vec![SpelledField {
801 name: Some("permit".to_string()),
802 optional: true,
803 ty: Spelled::Named("PERMIT".to_string()),
804 }],
805 }
806 );
807 assert_eq!(
808 canonicalize_spelling("MAP<permit ? : PERMIT>"),
809 "MAP<permit?: PERMIT>"
810 );
811 }
812
813 #[test]
814 fn unknown_map_spelling_parses_and_renders() {
815 assert_eq!(
818 parse_spelling("MAP<ANY>").expect("parses"),
819 Spelled::Generic {
820 name: "MAP".to_string(),
821 args: vec![SpelledField {
822 name: None,
823 optional: false,
824 ty: Spelled::Named("ANY".to_string()),
825 }],
826 }
827 );
828 assert_eq!(canonicalize_spelling("MAP< ANY >"), "MAP<ANY>");
829 assert_eq!(
830 render_structural(&TypeTag::MapOf(Arc::new(TypeTag::Any))),
831 "MAP<ANY>"
832 );
833 }
834
835 #[test]
836 fn map_of_checks_values_with_paths() {
837 let tag = TypeTag::MapOf(Arc::new(TypeTag::Int));
840 let good = map(&[("a", Value::int(1))]);
841 assert!(check_value(&tag, &good).is_ok());
842 let bad = map(&[("a", Value::string("x".to_string()))]);
843 let err = check_value(&tag, &bad).expect_err("bad value must fail");
844 assert!(format!("{err:#}").contains(".a"), "names the key: {err:#}");
845 let not_map = Value::list(vec![Value::int(1)]);
846 let err = check_value(&tag, ¬_map).expect_err("non-map must fail");
847 assert!(format!("{err:#}").contains("MAP<INT>"), "{err:#}");
848 }
849
850 #[test]
851 fn spellings_reject_malformed_input() {
852 for bad in [
853 "",
854 "LIST<>",
855 "LIST<A,>",
856 "MAP<name:>",
857 "LIST<A",
858 "list<MAP>",
859 "LIST<A>>",
860 ] {
861 assert!(parse_spelling(bad).is_err(), "{bad:?} must fail");
862 }
863 let mut deep = "INT".to_string();
865 for _ in 0..MAX_GENERIC_DEPTH + 1 {
866 deep = format!("LIST<{deep}>");
867 }
868 let err = parse_spelling(&deep).expect_err("over-deep spelling must fail");
869 assert!(err.contains("maximum generic depth"), "{err}");
870 }
871
872 #[test]
873 fn identical_spellings_share_one_interned_payload() {
874 let first = intern_composed(
875 "LIST<MAP>".to_string(),
876 TypeTag::ListOf(Arc::new(TypeTag::Map)),
877 );
878 let second = intern_composed(
879 "LIST<MAP>".to_string(),
880 TypeTag::ListOf(Arc::new(TypeTag::Map)),
881 );
882 let (TypeTag::ListOf(a), TypeTag::ListOf(b)) = (first, second) else {
883 panic!("composed tags keep their shape");
884 };
885 assert!(
886 Arc::ptr_eq(&a, &b),
887 "same canonical spelling shares one payload"
888 );
889 }
890
891 #[test]
892 fn structural_rendering_names_contents() {
893 let fields: Arc<[Field]> = Arc::from([Field {
894 name: "name".to_string(),
895 ty: TypeTag::String,
896 docs: "",
897 optional: false,
898 }]);
899 let record = TypeTag::Record(fields);
900 let list = TypeTag::ListOf(Arc::new(record));
901 assert_eq!(render_structural(&list), "LIST<MAP<name: STRING>>");
902 assert_eq!(render_structural(&TypeTag::Int), "INT");
903 }
904}