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::{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/// One named field of a [`TypeTag::Record`] schema.
23#[derive(Debug, Clone, Copy)]
24pub struct Field {
25    /// Field key as it appears in the value map.
26    pub name: &'static str,
27    /// Expected type of the field value. Nests freely.
28    pub ty: TypeTag,
29    /// What the field carries. Hand-registered schemas document every
30    /// field; fields synthesized from inline spellings carry none.
31    pub docs: &'static str,
32    /// Optional fields (`MAP<permit?: PERMIT>`) may be absent: missing
33    /// optional keys pass, missing required keys fail, extra keys
34    /// always fail.
35    pub optional: bool,
36}
37
38/// A declarative type for function params, returns, and bindings.
39///
40/// Builtins are variants, so exhaustiveness is compiler-checked wherever
41/// tags are matched. [`TypeTag::Custom`] carries a host descriptor for
42/// opaque handles. [`TypeTag::ListOf`], [`TypeTag::Record`], and
43/// [`TypeTag::MapOf`] shape composite values: bare `List` and `Map`
44/// are runtime words only, never declaration spellings. Unknown
45/// lists declare `LIST<ANY>`; unknown maps declare [`TypeTag::MapOf`]
46/// with [`TypeTag::Any`] values (`MAP<ANY>`).
47#[derive(Clone, Copy)]
48pub enum TypeTag {
49    String,
50    Int,
51    Float,
52    Bool,
53    List,
54    Map,
55    Pipe,
56    Handle,
57    Duration,
58    Path,
59    Semaphore,
60    Permit,
61    /// Every word: for parameters that accept anything by design
62    /// (coercion inputs, generic carriers). Boundary and coercion
63    /// both accept unconditionally, so the tag never lies.
64    Any,
65    Custom(&'static TypeDescriptor),
66    ListOf(&'static TypeTag),
67    Record(&'static [Field]),
68    MapOf(&'static TypeTag),
69}
70
71/// The `ANY` word for composed tags: one shared instance so generic
72/// spellings like `MAP<ANY>` never mint competing statics.
73pub const ANY_TAG: TypeTag = TypeTag::Any;
74
75/// The MAP word for `TypeTag::ListOf` metadata: one shared instance
76/// so generated registrations never mint competing statics.
77pub const MAP_TAG: TypeTag = TypeTag::Map;
78
79/// The LIST word for `TypeTag` metadata, matching `MAP_TAG`.
80pub const LIST_TAG: TypeTag = TypeTag::List;
81impl std::fmt::Debug for TypeTag {
82    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83        match self {
84            TypeTag::Custom(descriptor) => {
85                write!(f, "Custom({})", descriptor.name)
86            }
87            TypeTag::ListOf(element) => write!(f, "ListOf({element:?})"),
88            TypeTag::MapOf(values) => write!(f, "MapOf({values:?})"),
89            TypeTag::Record(fields) => {
90                let names: Vec<&str> = fields.iter().map(|field| field.name).collect();
91                write!(f, "Record({})", names.join(", "))
92            }
93            tag => write!(f, "{}", tag.name()),
94        }
95    }
96}
97
98impl std::fmt::Display for TypeTag {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(self.name())
101    }
102}
103impl TypeTag {
104    /// Display name: the builtin label, the custom descriptor name, or
105    /// the coarse word kind for shaped composites. Field detail renders
106    /// structurally, never through this string.
107    pub fn name(&self) -> &'static str {
108        match self {
109            TypeTag::String => "STRING",
110            TypeTag::Int => "INT",
111            TypeTag::Float => "FLOAT",
112            TypeTag::Bool => "BOOL",
113            TypeTag::List => "LIST",
114            TypeTag::Map => "MAP",
115            TypeTag::Pipe => "PIPE",
116            TypeTag::Handle => "HANDLE",
117            TypeTag::Duration => "DURATION",
118            TypeTag::Path => "PATH",
119            TypeTag::Semaphore => "SEMAPHORE",
120            TypeTag::Permit => "PERMIT",
121            TypeTag::Any => "ANY",
122            TypeTag::Custom(descriptor) => descriptor.name,
123            TypeTag::ListOf(_) => "LIST",
124            TypeTag::MapOf(_) => "MAP",
125            TypeTag::Record(_) => "MAP",
126        }
127    }
128
129    /// Resolve a builtin label to its tag. Custom, shaped, and unknown
130    /// names return `None`: customs resolve through the run's type
131    /// directory, shapes through the schema directory.
132    pub fn builtin(name: &str) -> Option<TypeTag> {
133        match name {
134            "STRING" => Some(TypeTag::String),
135            "INT" => Some(TypeTag::Int),
136            "FLOAT" => Some(TypeTag::Float),
137            "BOOL" => Some(TypeTag::Bool),
138            "LIST" => Some(TypeTag::List),
139            "MAP" => Some(TypeTag::Map),
140            "PIPE" => Some(TypeTag::Pipe),
141            "HANDLE" => Some(TypeTag::Handle),
142            "DURATION" => Some(TypeTag::Duration),
143            "PATH" => Some(TypeTag::Path),
144            "SEMAPHORE" => Some(TypeTag::Semaphore),
145            "PERMIT" => Some(TypeTag::Permit),
146            "ANY" => Some(TypeTag::Any),
147            _ => None,
148        }
149    }
150
151    /// Field table for [`TypeTag::Record`], `None` otherwise.
152    pub fn fields(&self) -> Option<&'static [Field]> {
153        match self {
154            TypeTag::Record(fields) => Some(fields),
155            _ => None,
156        }
157    }
158}
159
160/// Render one tag structurally: shaped tags name their contents
161/// (`LIST<MAP>`, `MAP<name: TYPE, ...>`), so signatures show the
162/// generics the extractor enforces instead of the coarse word
163/// kind. Single renderer for `DESCRIBE` and docs-gen alike.
164pub fn render_structural(tag: &TypeTag) -> String {
165    match tag {
166        TypeTag::ListOf(inner) => format!("LIST<{}>", render_structural(inner)),
167        TypeTag::MapOf(values) => format!("MAP<{}>", render_structural(values)),
168        TypeTag::Record(fields) => {
169            let field_list = fields
170                .iter()
171                .map(|field| {
172                    format!(
173                        "{}{}: {}",
174                        field.name,
175                        if field.optional { "?" } else { "" },
176                        render_structural(&field.ty)
177                    )
178                })
179                .collect::<Vec<_>>()
180                .join(", ");
181            format!("MAP<{field_list}>")
182        }
183        tag => tag.name().to_string(),
184    }
185}
186
187/// One parsed type spelling: a bare name or a generic application
188/// with positional (`LIST<MAP>`) or named (`MAP<name: STRING>`)
189/// arguments. A named field with a `?` suffix is optional
190/// (`MAP<permit?: PERMIT>`). Arity and shape enforce at resolution,
191/// never here: `LIST<A, B>` and `STRING<INT>` parse and fail later
192/// naming the rule each breaks.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub enum Spelled {
195    Named(String),
196    Generic {
197        name: String,
198        args: Vec<SpelledField>,
199    },
200}
201
202/// One generic argument: a bare type or a named (possibly optional)
203/// record field carrying its nested spelling.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct SpelledField {
206    pub name: Option<String>,
207    pub optional: bool,
208    pub ty: Spelled,
209}
210
211/// Canonical spelling: exactly the structural representation
212/// (`MAP<name: STRING, age: INT>`), so stored declarations, intern
213/// keys, alias targets, and rendered signatures are one form, never
214/// two. Any input spacing parses to the same shape and renders to
215/// this form, which keeps every parse pathway in agreement.
216/// Malformed input passes through trimmed for the resolver to
217/// reject by name.
218pub fn canonicalize_spelling(s: &str) -> String {
219    let stripped: String = s.chars().filter(|c| !c.is_whitespace()).collect();
220    match parse_spelling(&stripped) {
221        Ok(spelled) => render_spelled(&spelled),
222        Err(_) => s.trim().to_string(),
223    }
224}
225
226/// Render one parsed spelling in canonical form: `LIST<MAP>`,
227/// `MAP<name: STRING, age: INT>`. Deterministic by construction,
228/// so equal shapes share intern entries.
229pub fn render_spelled(spelled: &Spelled) -> String {
230    match spelled {
231        Spelled::Named(name) => name.clone(),
232        Spelled::Generic { name, args } => {
233            let rendered: Vec<String> = args
234                .iter()
235                .map(|field| match &field.name {
236                    Some(field_name) => format!(
237                        "{}{}: {}",
238                        field_name,
239                        if field.optional { "?" } else { "" },
240                        render_spelled(&field.ty)
241                    ),
242                    None => render_spelled(&field.ty),
243                })
244                .collect();
245            format!("{name}<{}>", rendered.join(", "))
246        }
247    }
248}
249
250/// Parse a spelling into its shape. Hand-rolled descent: the
251/// grammar guarantees shape for parser strings, this keeps the
252/// public resolution API safe for direct callers. All whitespace
253/// is stripped before parsing, so any spaced form parses.
254pub fn parse_spelling(s: &str) -> Result<Spelled, String> {
255    let text: String = s.chars().filter(|c| !c.is_whitespace()).collect();
256    let (spelled, rest) = parse_spelled_one(&text, 0)?;
257    if !rest.is_empty() {
258        return Err(format!(
259            "unexpected trailing text in type spelling: {rest:?}"
260        ));
261    }
262    Ok(spelled)
263}
264
265fn parse_spelled_one(text: &str, depth: usize) -> Result<(Spelled, &str), String> {
266    if depth > MAX_GENERIC_DEPTH {
267        return Err(format!(
268            "type spelling exceeds maximum generic depth of {MAX_GENERIC_DEPTH}: {text:?}"
269        ));
270    }
271    let mut chars = text.char_indices();
272    let Some((_, first)) = chars.next() else {
273        return Err("expected type name, found end of spelling".to_string());
274    };
275    if !first.is_ascii_uppercase() {
276        return Err(format!("expected uppercase type name, found {first:?}"));
277    }
278    let mut end = first.len_utf8();
279    for (idx, ch) in chars {
280        if ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_' {
281            end = idx + ch.len_utf8();
282        } else {
283            break;
284        }
285    }
286    let (name, mut rest) = text.split_at(end);
287    let name = name.to_string();
288    if !rest.starts_with('<') {
289        return Ok((Spelled::Named(name), rest));
290    }
291    rest = &rest[1..];
292    let mut args = Vec::new();
293    loop {
294        let (field_name, optional, after_name) = parse_spelled_field_name(rest);
295        let (arg, after_arg) = parse_spelled_one(after_name, depth + 1)?;
296        args.push(SpelledField {
297            name: field_name,
298            optional,
299            ty: arg,
300        });
301        rest = after_arg;
302        if let Some(tail) = rest.strip_prefix(',') {
303            rest = tail;
304            continue;
305        }
306        if let Some(tail) = rest.strip_prefix('>') {
307            rest = tail;
308            break;
309        }
310        return Err(format!(
311            "expected ',' or '>' in type spelling, found {rest:?}"
312        ));
313    }
314    Ok((Spelled::Generic { name, args }, rest))
315}
316
317/// Optional `field:` or `field?:` prefix of one generic argument. A
318/// lowercase name followed by an optional `?` and `:` claims the
319/// prefix (recording optionality); anything else leaves the text
320/// untouched for the bare-argument path.
321fn parse_spelled_field_name(text: &str) -> (Option<String>, bool, &str) {
322    let mut chars = text.char_indices();
323    let Some((_, first)) = chars.next() else {
324        return (None, false, text);
325    };
326    if !first.is_ascii_lowercase() {
327        return (None, false, text);
328    };
329    let mut end = first.len_utf8();
330    for (idx, ch) in chars {
331        if ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_' {
332            end = idx + ch.len_utf8();
333        } else {
334            break;
335        }
336    }
337    let (candidate, rest) = text.split_at(end);
338    let (optional, rest) = match rest.strip_prefix('?') {
339        Some(after) => (true, after),
340        None => (false, rest),
341    };
342    match rest.strip_prefix(':') {
343        Some(after) => (Some(candidate.to_string()), optional, after),
344        None => (None, false, text),
345    }
346}
347
348/// Process-global intern table for composed tags: one shared
349/// `&'static TypeTag` per distinct canonical spelling. The lock is
350/// held only for lookup/insert; leaf resolution happens outside it
351/// in the caller.
352static COMPOSED_TAGS: OnceLock<Mutex<HashMap<String, &'static TypeTag>>> = OnceLock::new();
353
354fn composed_table() -> &'static Mutex<HashMap<String, &'static TypeTag>> {
355    COMPOSED_TAGS.get_or_init(|| Mutex::new(HashMap::new()))
356}
357
358/// Look up an already-interned spelling. Resolution checks this
359/// before building, so identical spellings share one pointer.
360pub fn lookup_composed(canonical: &str) -> Option<&'static TypeTag> {
361    composed_table()
362        .lock()
363        .unwrap_or_else(|poisoned| poisoned.into_inner())
364        .get(canonical)
365        .copied()
366}
367
368/// Intern one composed tag under its canonical spelling: a raced
369/// build stores once (the loser drops its duplicate leaves, still
370/// bounded by distinct spellings per process). Exactly one leak
371/// per distinct spelling per process: the leak is the point (a
372/// process-global `'static` home for composed tags), localized
373/// here so the rest of the crate stays deny-clean.
374#[allow(clippy::disallowed_methods)]
375pub fn intern_composed(canonical: String, tag: TypeTag) -> &'static TypeTag {
376    let mut table = composed_table()
377        .lock()
378        .unwrap_or_else(|poisoned| poisoned.into_inner());
379    if let Some(hit) = table.get(&canonical).copied() {
380        return hit;
381    }
382    let leaked: &'static TypeTag = Box::leak(Box::new(tag));
383    table.insert(canonical, leaked);
384    leaked
385}
386
387/// Check a value against a tag, failing naming the expected shape and
388/// the offending path. Records reject missing AND extra fields: a
389/// schema is a lock, not a hint.
390pub fn check_value(tag: &TypeTag, value: &Value) -> Result<()> {
391    check_value_at(tag, value, "$")
392}
393
394/// Shape conformance with a caller-chosen path prefix: the single
395/// implementation behind generated parameter extractors, so boundary
396/// errors name the function, the parameter, and the nested position
397/// (`F() argument `$maps`[1]: expected MAP, got INT`) with no second
398/// copy of the shape logic anywhere.
399pub fn check_value_at(tag: &TypeTag, value: &Value, path: &str) -> Result<()> {
400    match tag {
401        TypeTag::Any => Ok(()),
402        TypeTag::String if value.as_str().is_some() => Ok(()),
403        TypeTag::Int if value.as_i64().is_some() => Ok(()),
404        TypeTag::Float if value.as_f64().is_some() => Ok(()),
405        TypeTag::Bool if value.as_bool().is_some() => Ok(()),
406        TypeTag::Pipe if value.as_pipe_handle().is_some() => Ok(()),
407        TypeTag::Handle if value.as_handle().is_some() => Ok(()),
408        TypeTag::Duration if value.as_duration().is_some() => Ok(()),
409        TypeTag::Path if value.as_path().is_some() => Ok(()),
410        TypeTag::Semaphore if value.as_semaphore().is_some() => Ok(()),
411        TypeTag::Permit if value.as_permit().is_some() => Ok(()),
412        TypeTag::List => match value.as_list() {
413            Some(_) => Ok(()),
414            None => bail!("{path}: expected LIST, got {}", value.type_name()),
415        },
416        TypeTag::Map => match value.as_map() {
417            Some(_) => Ok(()),
418            None => bail!("{path}: expected MAP, got {}", value.type_name()),
419        },
420        TypeTag::ListOf(element) => match value.as_list() {
421            Some(items) => {
422                for (idx, item) in items.iter().enumerate() {
423                    check_value_at(element, item, &format!("{path}[{idx}]"))?;
424                }
425                Ok(())
426            }
427            None => bail!("{path}: expected LIST, got {}", value.type_name()),
428        },
429        TypeTag::MapOf(values) => match value.as_map() {
430            Some(map) => {
431                for (key, item) in map.iter() {
432                    check_value_at(values, item, &format!("{path}.{key}"))?;
433                }
434                Ok(())
435            }
436            None => bail!(
437                "{path}: expected {}, got {}",
438                render_structural(tag),
439                value.type_name()
440            ),
441        },
442        TypeTag::Record(fields) => match value.as_map() {
443            Some(map) => {
444                for field in *fields {
445                    match map.get(field.name) {
446                        Some(item) => {
447                            check_value_at(&field.ty, item, &format!("{path}.{}", field.name))?;
448                        }
449                        None if field.optional => {}
450                        None => bail!(
451                            "{path}: missing field '{}'; expected fields: {}",
452                            field.name,
453                            field_list(fields),
454                        ),
455                    }
456                }
457                for key in map.keys() {
458                    if !fields.iter().any(|field| field.name == key) {
459                        bail!(
460                            "{path}: unknown field '{key}'; expected fields: {}",
461                            field_list(fields),
462                        );
463                    }
464                }
465                Ok(())
466            }
467            None => bail!("{path}: expected MAP, got {}", value.type_name()),
468        },
469        TypeTag::Custom(descriptor) => {
470            if value.type_name() == descriptor.name {
471                Ok(())
472            } else {
473                bail!(
474                    "{path}: expected {}, got {}",
475                    descriptor.name,
476                    value.type_name(),
477                )
478            }
479        }
480        _ => bail!("{path}: expected {}, got {}", tag.name(), value.type_name()),
481    }
482}
483
484fn field_list(fields: &[Field]) -> String {
485    fields
486        .iter()
487        .map(|field| field.name)
488        .collect::<Vec<_>>()
489        .join(", ")
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495    use std::collections::BTreeMap;
496
497    fn map(entries: &[(&str, Value)]) -> Value {
498        Value::map(
499            entries
500                .iter()
501                .map(|(k, v)| ((*k).to_string(), v.clone()))
502                .collect::<BTreeMap<_, _>>(),
503        )
504    }
505
506    #[test]
507    fn builtin_round_trips_through_names() {
508        for name in [
509            "STRING",
510            "INT",
511            "FLOAT",
512            "BOOL",
513            "LIST",
514            "MAP",
515            "PIPE",
516            "HANDLE",
517            "DURATION",
518            "PATH",
519            "SEMAPHORE",
520            "PERMIT",
521        ] {
522            let tag = TypeTag::builtin(name).expect("builtin");
523            assert_eq!(tag.name(), name);
524        }
525        assert!(TypeTag::builtin("NOPE").is_none());
526        assert!(TypeTag::builtin("string").is_none());
527    }
528
529    #[test]
530    fn scalars_conform_by_discriminant() {
531        assert!(check_value(&TypeTag::String, &Value::string("x".to_string())).is_ok());
532        assert!(check_value(&TypeTag::Int, &Value::int(1)).is_ok());
533        assert!(check_value(&TypeTag::String, &Value::int(1)).is_err());
534        // No cross coercion here: 3.0 is FLOAT, not INT.
535        assert!(check_value(&TypeTag::Int, &Value::float(3.0)).is_err());
536    }
537
538    #[test]
539    fn record_rejects_missing_and_extra_fields() {
540        static FIELDS: &[Field] = &[
541            Field {
542                name: "a",
543                ty: TypeTag::String,
544                docs: "",
545                optional: false,
546            },
547            Field {
548                name: "b",
549                ty: TypeTag::Int,
550                docs: "",
551                optional: false,
552            },
553        ];
554        let tag = TypeTag::Record(FIELDS);
555        let good = map(&[("a", Value::string("x".to_string())), ("b", Value::int(1))]);
556        assert!(check_value(&tag, &good).is_ok());
557        let missing = map(&[("a", Value::string("x".to_string()))]);
558        let err = check_value(&tag, &missing).expect_err("missing must fail");
559        assert!(format!("{err:#}").contains('b'), "names the field: {err:#}");
560        let extra = map(&[
561            ("a", Value::string("x".to_string())),
562            ("b", Value::int(1)),
563            ("c", Value::int(2)),
564        ]);
565        let err = check_value(&tag, &extra).expect_err("extra must fail");
566        assert!(format!("{err:#}").contains('c'), "names the field: {err:#}");
567        let mistyped = map(&[
568            ("a", Value::string("x".to_string())),
569            ("b", Value::string("nope".to_string())),
570        ]);
571        assert!(check_value(&tag, &mistyped).is_err());
572    }
573
574    #[test]
575    fn record_optional_fields_may_be_absent() {
576        // Optional fields validate when present and pass when absent;
577        // required fields and extra keys still fail exactly.
578        static FIELDS: &[Field] = &[
579            Field {
580                name: "held",
581                ty: TypeTag::Int,
582                docs: "",
583                optional: false,
584            },
585            Field {
586                name: "permit",
587                ty: TypeTag::String,
588                docs: "",
589                optional: true,
590            },
591        ];
592        let tag = TypeTag::Record(FIELDS);
593        let full = map(&[
594            ("held", Value::int(1)),
595            ("permit", Value::string("p".to_string())),
596        ]);
597        assert!(check_value(&tag, &full).is_ok());
598        let missing_optional = map(&[("held", Value::int(0))]);
599        assert!(check_value(&tag, &missing_optional).is_ok());
600        let missing_required = map(&[("permit", Value::string("p".to_string()))]);
601        let err = check_value(&tag, &missing_required).expect_err("missing required must fail");
602        assert!(
603            format!("{err:#}").contains("held"),
604            "names the field: {err:#}"
605        );
606        let mistyped_optional = map(&[("held", Value::int(1)), ("permit", Value::int(2))]);
607        assert!(check_value(&tag, &mistyped_optional).is_err());
608    }
609
610    #[test]
611    fn list_of_checks_elements_with_index() {
612        let tag = TypeTag::ListOf(&TypeTag::Int);
613        let good = Value::list(vec![Value::int(1), Value::int(2)]);
614        assert!(check_value(&tag, &good).is_ok());
615        let bad = Value::list(vec![Value::int(1), Value::string("x".to_string())]);
616        let err = check_value(&tag, &bad).expect_err("bad element must fail");
617        assert!(
618            format!("{err:#}").contains("[1]"),
619            "names the index: {err:#}"
620        );
621    }
622
623    #[test]
624    fn canonicalization_unifies_input_spacing() {
625        // Every spaced form lands on the structural representation:
626        // the stored, keyed, and rendered form is one form.
627        assert_eq!(canonicalize_spelling("LIST<MAP>"), "LIST<MAP>");
628        assert_eq!(canonicalize_spelling("LIST< MAP >"), "LIST<MAP>");
629        assert_eq!(
630            canonicalize_spelling("MAP<name:STRING,age:INT>"),
631            "MAP<name: STRING, age: INT>"
632        );
633        assert_eq!(
634            canonicalize_spelling("MAP<name : STRING>"),
635            "MAP<name: STRING>"
636        );
637    }
638
639    #[test]
640    fn spellings_parse_to_shapes() {
641        assert_eq!(
642            parse_spelling("LIST<MAP>").expect("parses"),
643            Spelled::Generic {
644                name: "LIST".to_string(),
645                args: vec![SpelledField {
646                    name: None,
647                    optional: false,
648                    ty: Spelled::Named("MAP".to_string()),
649                }],
650            }
651        );
652        assert_eq!(
653            parse_spelling("MAP<name: STRING>").expect("parses"),
654            Spelled::Generic {
655                name: "MAP".to_string(),
656                args: vec![SpelledField {
657                    name: Some("name".to_string()),
658                    optional: false,
659                    ty: Spelled::Named("STRING".to_string()),
660                }],
661            }
662        );
663        // Arity violations parse; resolution rejects them naming
664        // the rule (`LIST` arity, `MAP` bare fields).
665        assert!(parse_spelling("LIST<A, B>").is_ok());
666        assert!(parse_spelling("MAP<STRING>").is_ok());
667    }
668
669    #[test]
670    fn optional_field_spelling_parses_and_renders() {
671        // A `?` suffix marks one field optional: it parses into the
672        // flag, canonicalizes with the mark, and renders back.
673        assert_eq!(
674            parse_spelling("MAP<permit?: PERMIT>").expect("parses"),
675            Spelled::Generic {
676                name: "MAP".to_string(),
677                args: vec![SpelledField {
678                    name: Some("permit".to_string()),
679                    optional: true,
680                    ty: Spelled::Named("PERMIT".to_string()),
681                }],
682            }
683        );
684        assert_eq!(
685            canonicalize_spelling("MAP<permit ? : PERMIT>"),
686            "MAP<permit?: PERMIT>"
687        );
688    }
689
690    #[test]
691    fn unknown_map_spelling_parses_and_renders() {
692        // `MAP<ANY>` is the explicit unknown map: it parses like any
693        // other generic spelling and renders back to itself.
694        assert_eq!(
695            parse_spelling("MAP<ANY>").expect("parses"),
696            Spelled::Generic {
697                name: "MAP".to_string(),
698                args: vec![SpelledField {
699                    name: None,
700                    optional: false,
701                    ty: Spelled::Named("ANY".to_string()),
702                }],
703            }
704        );
705        assert_eq!(canonicalize_spelling("MAP< ANY >"), "MAP<ANY>");
706        assert_eq!(
707            render_structural(&TypeTag::MapOf(&TypeTag::Any)),
708            "MAP<ANY>"
709        );
710    }
711
712    #[test]
713    fn map_of_checks_values_with_paths() {
714        // Value shapes walk every entry with key paths; non-maps fail
715        // naming the structural shape, never the coarse word.
716        let tag = TypeTag::MapOf(&TypeTag::Int);
717        let good = map(&[("a", Value::int(1))]);
718        assert!(check_value(&tag, &good).is_ok());
719        let bad = map(&[("a", Value::string("x".to_string()))]);
720        let err = check_value(&tag, &bad).expect_err("bad value must fail");
721        assert!(format!("{err:#}").contains(".a"), "names the key: {err:#}");
722        let not_map = Value::list(vec![Value::int(1)]);
723        let err = check_value(&tag, &not_map).expect_err("non-map must fail");
724        assert!(format!("{err:#}").contains("MAP<INT>"), "{err:#}");
725    }
726
727    #[test]
728    fn spellings_reject_malformed_input() {
729        for bad in [
730            "",
731            "LIST<>",
732            "LIST<A,>",
733            "MAP<name:>",
734            "LIST<A",
735            "list<MAP>",
736            "LIST<A>>",
737        ] {
738            assert!(parse_spelling(bad).is_err(), "{bad:?} must fail");
739        }
740        // Depth cap counts syntactic nesting; deeper bails naming it.
741        let mut deep = "INT".to_string();
742        for _ in 0..MAX_GENERIC_DEPTH + 1 {
743            deep = format!("LIST<{deep}>");
744        }
745        let err = parse_spelling(&deep).expect_err("over-deep spelling must fail");
746        assert!(err.contains("maximum generic depth"), "{err}");
747    }
748
749    #[test]
750    fn identical_spellings_share_one_interned_pointer() {
751        let first = intern_composed("LIST<MAP>".to_string(), TypeTag::ListOf(&TypeTag::Map));
752        let second = intern_composed("LIST<MAP>".to_string(), TypeTag::ListOf(&TypeTag::Map));
753        assert!(
754            std::ptr::eq(first, second),
755            "same canonical spelling interns once"
756        );
757    }
758
759    #[test]
760    fn structural_rendering_names_contents() {
761        static FIELDS: &[Field] = &[Field {
762            name: "name",
763            ty: TypeTag::String,
764            docs: "",
765            optional: false,
766        }];
767        static RECORD: TypeTag = TypeTag::Record(FIELDS);
768        static LIST: TypeTag = TypeTag::ListOf(&RECORD);
769        assert_eq!(render_structural(&LIST), "LIST<MAP<name: STRING>>");
770        assert_eq!(render_structural(&TypeTag::Int), "INT");
771    }
772}