Skip to main content

oxdock_parser/
tag.rs

1//! Static type tags for function metadata and variable bindings.
2//!
3//! The DSL surface stays text (`LET $x: MAP`, `DESCRIBE`), but everything
4//! the engine reasons about is a [`TypeTag`]: builtins are enum variants
5//! a typo cannot spell, host handles carry their descriptor, and shaped
6//! maps carry their field table. Strings survive only at display and
7//! parse boundaries through [`TypeTag::name`] and [`TypeTag::builtin`].
8
9use anyhow::{Result, bail};
10
11use std::collections::HashMap;
12use std::sync::{Arc, Mutex, OnceLock};
13
14use crate::{TypeDescriptor, Value};
15
16/// Maximum generic nesting for one spelling: syntactic `<` levels
17/// and alias hops share this budget at resolution, so neither deep
18/// nesting nor long alias chains can wedge the pass. Parser-bounded
19/// scripts never approach it; exceeding it bails naming the shape.
20pub const MAX_GENERIC_DEPTH: usize = 8;
21
22/// Build a union tag from member variant idents: the spelling
23/// derives from the members' own names at construction, so no
24/// hand-written string can drift from the set it names. Unknown
25/// idents fail at the `TypeTag::` path, so misspelled members never
26/// compile. Empty sets are unrepresentable (one or more idents).
27/// Runtime-built (shared `Arc` payloads, freed with the last
28/// owner): not for `const` or `static` initializers.
29#[macro_export]
30macro_rules! union_tag {
31    ($($member:ident),+ $(,)?) => {
32        $crate::TypeTag::union(vec![$($crate::TypeTag::$member),+])
33    };
34}
35
36/// One named field of a [`TypeTag::Record`] schema.
37#[derive(Debug, Clone)]
38pub struct Field {
39    /// Field key as it appears in the value map. Owned: schema names
40    /// arrive from runtime spellings, so interning them would leak
41    /// per distinct spelling.
42    pub name: String,
43    /// Expected type of the field value. Nests freely.
44    pub ty: TypeTag,
45    /// What the field carries. Hand-registered schemas document every
46    /// field; fields synthesized from inline spellings carry none.
47    pub docs: &'static str,
48    /// Optional fields (`MAP<permit?: PERMIT>`) may be absent: missing
49    /// optional keys pass, missing required keys fail, extra keys
50    /// always fail.
51    pub optional: bool,
52}
53
54/// A declarative type for function params, returns, and bindings.
55///
56/// Builtins are variants, so exhaustiveness is compiler-checked wherever
57/// tags are matched. [`TypeTag::Custom`] carries a host descriptor for
58/// opaque handles. [`TypeTag::ListOf`], [`TypeTag::Record`],
59/// [`TypeTag::MapOf`], and [`TypeTag::Union`] share their payloads
60/// through [`Arc`]: cheap clones, no leaks, freed
61/// when the last owner drops. Bare `List` and `Map`
62/// are runtime words only, never declaration spellings. Unknown
63/// lists declare `LIST<ANY>`; unknown maps declare [`TypeTag::MapOf`]
64/// with [`TypeTag::Any`] values (`MAP<ANY>`).
65#[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    /// Every word: for parameters that accept anything by design
80    /// (coercion inputs, generic carriers). Boundary and coercion
81    /// both accept unconditionally, so the tag never lies.
82    Any,
83    Custom(&'static TypeDescriptor),
84    ListOf(Arc<TypeTag>),
85    Record(Arc<[Field]>),
86    MapOf(Arc<TypeTag>),
87    /// Admission set over existing words (`STRING|PIPE`): values stay
88    /// whatever words the members name, so no word reader changes.
89    /// The spelling is computed once at construction from the
90    /// members and shared; `name()` borrows it with no allocation.
91    /// Members are flat, deduplicated, and order-free by
92    /// construction, and every comparison is subset-based, so member
93    /// order never matters.
94    Union(Arc<str>, Arc<[TypeTag]>),
95}
96
97/// The `ANY` word for composed tags: one shared instance so generic
98/// spellings like `MAP<ANY>` never mint competing statics.
99pub const ANY_TAG: TypeTag = TypeTag::Any;
100
101/// The MAP word for `TypeTag::ListOf` metadata: one shared instance
102/// so generated registrations never mint competing statics.
103pub const MAP_TAG: TypeTag = TypeTag::Map;
104
105/// The LIST word for `TypeTag` metadata, matching `MAP_TAG`.
106pub 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            // Unions render their stored spelling, like shaped tags
120            // render their contents: the spelling names every member,
121            // so no member detail is lost. Bare words share the
122            // `name()` delegation below, which is itself exhaustive.
123            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    /// Build a union tag from resolved members, computing the
136    /// canonical spelling from their structural renders. Single
137    /// source for the spelling: the macro and the resolve path
138    /// share it, so no hand-written string exists anywhere. Members
139    /// pass through as given (no normalization here): the resolve
140    /// path normalizes through its own pass before calling.
141    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    /// Display name: the builtin label, the custom descriptor name, or
152    /// the coarse word kind for shaped composites. Field detail renders
153    /// structurally, never through this string. Borrowed in every
154    /// case: literals for words, the stored spelling for unions.
155    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            // Borrowed from the stored spelling: computed once at
171            // construction, shared by every clone.
172            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    /// Resolve a builtin label to its tag. Custom, shaped, and unknown
181    /// names return `None`: customs resolve through the run's type
182    /// directory, shapes through the schema directory.
183    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    /// Field table for [`TypeTag::Record`], `None` otherwise.
203    /// Every non-record variant is listed: a new variant fails to
204    /// compile here until its fieldlessness is explicit, so record
205    /// structure can never silently leak into (or out of) another tag.
206    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
230/// Render one tag structurally: shaped tags name their contents
231/// (`LIST<MAP>`, `MAP<name: TYPE, ...>`), so signatures show the
232/// generics the extractor enforces instead of the coarse word
233/// kind. Single renderer for `DESCRIBE` and docs-gen alike.
234pub 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/// One parsed type spelling: a bare name, a generic application
258/// with positional (`LIST<MAP>`) or named (`MAP<name: STRING>`)
259/// arguments, or a union (`STRING|PIPE`) of sibling shapes. A named
260/// field with a `?` suffix is optional
261/// (`MAP<permit?: PERMIT>`). Arity and shape enforce at resolution,
262/// never here: `LIST<A, B>` and `STRING<INT>` parse and fail later
263/// naming the rule each breaks.
264#[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/// One generic argument: a bare type or a named (possibly optional)
275/// record field carrying its nested spelling.
276#[derive(Debug, Clone, PartialEq, Eq)]
277pub struct SpelledField {
278    pub name: Option<String>,
279    pub optional: bool,
280    pub ty: Spelled,
281}
282
283/// Canonical spelling: exactly the structural representation
284/// (`MAP<name: STRING, age: INT>`), so stored declarations, intern
285/// keys, alias targets, and rendered signatures are one form, never
286/// two. Any input spacing parses to the same shape and renders to
287/// this form, which keeps every parse pathway in agreement.
288/// Malformed input passes through trimmed for the resolver to
289/// reject by name.
290pub 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
298/// Render one parsed spelling in canonical form: `LIST<MAP>`,
299/// `MAP<name: STRING, age: INT>`, `STRING|PIPE`. Deterministic by
300/// construction, so equal shapes share intern entries.
301pub 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
327/// Parse a spelling into its shape. Hand-rolled descent: the
328/// grammar guarantees shape for parser strings, this keeps the
329/// public resolution API safe for direct callers. All whitespace
330/// is stripped before parsing, so any spaced form parses.
331pub 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    // Union tail: `A|B|C` accumulates sibling shapes at any level, so
348    // unions compose inside generic arguments as well as at top
349    // level. Members share the depth budget (siblings, not nesting);
350    // every piece consumes input, so the loop always terminates.
351    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
412/// Optional `field:` or `field?:` prefix of one generic argument. A
413/// lowercase name followed by an optional `?` and `:` claims the
414/// prefix (recording optionality); anything else leaves the text
415/// untouched for the bare-argument path.
416fn 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
443/// Process-global intern table for composed tags: one shared
444/// `&'static TypeTag` per distinct canonical spelling. The lock is
445/// held only for lookup/insert; leaf resolution happens outside it
446/// in the caller.
447static 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
453/// Look up an already-interned spelling. Resolution checks this
454/// before building, so identical spellings share one `Arc` payload
455/// family (pointer identity holds across clones).
456pub 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
464/// Intern one composed tag under its canonical spelling: a raced
465/// build stores once (the loser drops its duplicate, still bounded
466/// by distinct spellings per process). The table owns every entry
467/// (reachable, freed with the process like any cache): clones share
468/// payloads through `Arc`, so identity holds without leaks.
469pub 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
480/// Check a value against a tag, failing naming the expected shape and
481/// the offending path. Records reject missing AND extra fields: a
482/// schema is a lock, not a hint.
483pub fn check_value(tag: &TypeTag, value: &Value) -> Result<()> {
484    check_value_at(tag, value, "$")
485}
486
487/// Shape conformance with a caller-chosen path prefix: the single
488/// implementation behind generated parameter extractors, so boundary
489/// errors name the function, the parameter, and the nested position
490/// (`F() argument `$maps`[1]: expected MAP, got INT`) with no second
491/// copy of the shape logic anywhere.
492pub 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        // Admission set: any member passing admits the value, so
574        // member order never matters. All members failing names the
575        // set, never a single member.
576        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        // Scalar mismatches: every scalar is listed so a new word
591        // kind fails to compile here until its mismatch text is
592        // explicit. No future variant can silently inherit this bail.
593        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        // No cross coercion here: 3.0 is FLOAT, not INT.
658        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        // Optional fields validate when present and pass when absent;
700        // required fields and extra keys still fail exactly.
701        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        // Every spaced form lands on the structural representation:
749        // the stored, keyed, and rendered form is one form.
750        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        // Arity violations parse; resolution rejects them naming
787        // the rule (`LIST` arity, `MAP` bare fields).
788        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        // A `?` suffix marks one field optional: it parses into the
795        // flag, canonicalizes with the mark, and renders back.
796        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        // `MAP<ANY>` is the explicit unknown map: it parses like any
816        // other generic spelling and renders back to itself.
817        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        // Value shapes walk every entry with key paths; non-maps fail
838        // naming the structural shape, never the coarse word.
839        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, &not_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        // Depth cap counts syntactic nesting; deeper bails naming it.
864        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}