Skip to main content

relon_parser/
token.rs

1use ordered_float::OrderedFloat;
2use std::fmt::{Display, Formatter};
3use std::sync::atomic::{AtomicU32, Ordering};
4use std::sync::Arc;
5
6/// Internal TypeNode head used by the lowered representation of `#enum`.
7/// User source cannot write this name directly as public enum syntax.
8pub const INTERNAL_ENUM_TYPE_NAME: &str = "__RelonEnum";
9
10/// Stable identifier assigned to every `Node` at parse time.
11///
12/// Used as the key in side-tables maintained by `relon-analyzer` (resolved
13/// references, desugar caches, diagnostics) so analyzer passes can attach
14/// information without mutating the AST itself.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
16pub struct NodeId(pub u32);
17
18impl NodeId {
19    /// Sentinel id for synthetic nodes built outside the parser (e.g. by
20    /// the evaluator when fabricating a `Type` node mid-flight). Analyzer
21    /// side-tables must not key on this value.
22    pub const SYNTHETIC: NodeId = NodeId(0);
23
24    /// Allocate a fresh, process-wide-unique id.
25    ///
26    /// Public so AST rewriters outside the parser (analyzer, evaluator
27    /// fabricated nodes, host transforms) can mint ids that won't collide
28    /// with parser-emitted ones.
29    pub fn alloc() -> NodeId {
30        // Start at 1 so `SYNTHETIC` (0) stays distinct from any real node.
31        static COUNTER: AtomicU32 = AtomicU32::new(1);
32        NodeId(COUNTER.fetch_add(1, Ordering::Relaxed))
33    }
34}
35
36#[derive(Debug, PartialEq, Clone, Eq, Copy, Default, Hash)]
37pub struct TokenPosition {
38    pub line: u32,
39    pub column: usize,
40    pub offset: usize,
41}
42
43#[derive(Debug, PartialEq, Clone, Eq, Copy, Default, Hash)]
44pub struct TokenRange {
45    pub start: TokenPosition,
46    pub end: TokenPosition,
47}
48
49impl From<TokenRange> for miette::SourceSpan {
50    fn from(range: TokenRange) -> Self {
51        let len = range.end.offset.saturating_sub(range.start.offset);
52        (range.start.offset, len).into()
53    }
54}
55
56#[derive(Debug, PartialEq, Clone)]
57// `Dynamic` carries a full `Node` so this variant is significantly
58// larger than the others. The values are parser/AST-internal and
59// always wrapped in tuples or larger AST types in practice; the size
60// disparity isn't worth boxing every key access for.
61#[allow(clippy::large_enum_variant)]
62pub enum TokenKey {
63    Dummy,
64    Index(usize, bool),               // index, is_optional
65    String(String, TokenRange, bool), // name, range, is_optional
66    Dynamic(Node, bool),              // expr, is_optional
67    Spread(TokenRange),
68}
69
70impl TokenKey {
71    pub fn name(&self) -> String {
72        match self {
73            TokenKey::Dummy => "_".to_string(),
74            TokenKey::Index(i, _) => i.to_string(),
75            TokenKey::String(s, _, _) => s.clone(),
76            TokenKey::Dynamic(_, _) => "<dynamic>".to_string(),
77            TokenKey::Spread(_) => "...".to_string(),
78        }
79    }
80
81    pub fn to_string_key(&self) -> String {
82        self.name()
83    }
84
85    pub fn is_optional(&self) -> bool {
86        match self {
87            TokenKey::Index(_, opt) => *opt,
88            TokenKey::String(_, _, opt) => *opt,
89            TokenKey::Dynamic(_, opt) => *opt,
90            _ => false,
91        }
92    }
93}
94
95#[derive(Debug, PartialEq, Clone, Hash, Eq)]
96pub struct TokenId(pub String, pub TokenRange);
97
98impl TokenId {
99    pub fn name(&self) -> &str {
100        &self.0
101    }
102}
103
104/// Represents a single argument in a function call or decorator.
105/// Can be positional or named (keyword).
106#[derive(Debug, PartialEq, Clone)]
107pub struct CallArg {
108    pub name: Option<String>,
109    pub value: Node,
110}
111
112#[derive(Debug, PartialEq, Clone)]
113pub struct Decorator {
114    pub path: Vec<TokenKey>,
115    pub args: Vec<CallArg>,
116    pub range: TokenRange,
117}
118
119/// One of the five fixed shapes a `#name` directive can take. The shape
120/// is determined by the directive's name (looked up at parse time) and
121/// drives parser dispatch + analyzer / evaluator interpretation.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123pub enum DirectiveShape {
124    /// `#name` — no body. Example: `#internal`.
125    Bare,
126    /// `#name <expr>` — single value. Example: `#default 0`,
127    /// `#expect "msg"`, `#brand Color`.
128    Value,
129    /// `#name <ident> <body-expr>` — one named declaration with a
130    /// single body expression (no colon). Example:
131    /// `#schema User { String name: * }`. Inside a dict literal,
132    /// `#schema X: { ... }` retains the dict-field grammar — the `:`
133    /// belongs to the field separator, not the directive.
134    NameBody,
135    /// `#enum Name { Variant, Variant { field: Type } }` — Rust-like enum
136    /// declaration lowered to the internal tagged-enum schema shape.
137    Enum,
138    /// `#import <bindspec> from <string>`. Example:
139    /// `#import string from "std/string"`,
140    /// `#import * from "std/list"`,
141    /// `#import { upper, lower as lo } from "std/string"`.
142    Import,
143    /// `#main(<type> <ident> [, ...]*) [-> <type>]`. Example:
144    /// `#main(User u, Cart cart) -> Result<Order>`.
145    Main,
146}
147
148/// The body of a parsed `#name ...` directive, dispatched per
149/// [`DirectiveShape`].
150#[derive(Debug, PartialEq, Clone)]
151pub enum DirectiveBody {
152    Bare,
153    Value(Box<Node>),
154    /// Single named declaration: `<ident>[<T, ...>] <body-expr>` (no colon).
155    /// `generics` carries the optional type-parameter list declared after
156    /// the name (e.g. `Result<T, E>` → `["T", "E"]`); empty when absent.
157    ///
158    /// `methods` and `schema_no_auto_derives` carry the optional
159    /// `with { ... }` extension block (Phase A of the trait-bound /
160    /// schema-method system; see `docs/internal/archive/type-constraints-spec.md`).
161    /// Both are empty when no `with` block follows the body.
162    NameBody {
163        name: String,
164        name_range: TokenRange,
165        generics: Vec<String>,
166        body: Box<Node>,
167        /// Methods declared inside the trailing `with { ... }` block.
168        /// Order preserves source order. Each method may carry method-level
169        /// `#derive <Constraint>` pragmas and an `#native` flag.
170        methods: Vec<SchemaMethod>,
171        /// Schema-level `#no_auto_derive <Constraint>` directives that
172        /// appear directly inside `with { ... }` (no method follows).
173        /// Constraint names are stored as bare strings; the analyzer
174        /// validates them.
175        schema_no_auto_derives: Vec<String>,
176    },
177    Import {
178        spec: DirectiveImportSpec,
179        path: String,
180        path_range: TokenRange,
181        /// Optional integrity pin: `#import <spec> from "path" sha256:"..."`.
182        /// When present, the workspace loader verifies the loaded source's
183        /// digest against this value and refuses the import on mismatch.
184        /// v3++ b-2 wires sha256 only; the [`HashAlgorithm`] enum reserves
185        /// space for additional algorithms (sha512, blake3, ...) without
186        /// churning the AST surface again.
187        integrity: Option<IntegrityHash>,
188    },
189    Main {
190        params: Vec<DirectiveMainParam>,
191        /// Optional `-> Type` declared after the parameter list. When
192        /// `None`, the entry's return value is left unchecked.
193        return_type: Option<TypeNode>,
194    },
195}
196
197/// Hash algorithm used by an [`IntegrityHash`] pin on `#import`. Only
198/// `Sha256` is wired in v3++ b-2; the enum exists so future agents can
199/// add `Sha512` / `Blake3` (or SRI multi-algo) without an AST shape
200/// change.
201#[derive(Debug, PartialEq, Eq, Clone, Copy, Hash)]
202pub enum HashAlgorithm {
203    Sha256,
204}
205
206impl HashAlgorithm {
207    /// Canonical lowercase identifier as it appears in source
208    /// (`sha256:"..."`). Lookups in the parser / analyzer compare
209    /// against this so the casing is fixed in one place.
210    pub fn as_str(&self) -> &'static str {
211        match self {
212            HashAlgorithm::Sha256 => "sha256",
213        }
214    }
215
216    /// Reverse of [`HashAlgorithm::as_str`]. Returns `None` for unknown
217    /// names so the caller can emit a position-aware parse / analyzer
218    /// diagnostic.
219    pub fn from_ident(name: &str) -> Option<Self> {
220        match name {
221            "sha256" => Some(HashAlgorithm::Sha256),
222            _ => None,
223        }
224    }
225
226    /// Expected hex-digest length (one byte = two hex chars) for the
227    /// algorithm. Used by the analyzer to reject obvious typos before
228    /// it tries to verify the import.
229    pub fn hex_len(&self) -> usize {
230        match self {
231            HashAlgorithm::Sha256 => 64,
232        }
233    }
234}
235
236/// Inline integrity pin on a `#import` directive. The source form is
237/// `<algorithm>:"<hex>"` (e.g. `sha256:"abc..."`); the parser does not
238/// validate the hex string itself — that work happens in the analyzer
239/// so the diagnostic carries a real source span. The algorithm name
240/// is preserved verbatim alongside the parsed [`HashAlgorithm`] enum
241/// so the analyzer can render the offending identifier when it does
242/// not match any known algorithm.
243#[derive(Debug, PartialEq, Eq, Clone)]
244pub struct IntegrityHash {
245    /// Parsed algorithm. `None` when the source identifier did not
246    /// match any known algorithm — the analyzer surfaces the typo /
247    /// unsupported-algo diagnostic before the loader is consulted.
248    pub algorithm: Option<HashAlgorithm>,
249    /// Verbatim source identifier (`sha256`, `sha512`, ...). Kept
250    /// alongside [`Self::algorithm`] so error messages can echo the
251    /// exact spelling the author used.
252    pub algorithm_text: String,
253    pub hex: String,
254    /// Full source range covering `<algorithm>:"<hex>"` so analyzer
255    /// diagnostics (`unknown algorithm`, `hex length mismatch`, …) can
256    /// point at the right span.
257    pub range: TokenRange,
258}
259
260/// Bindspec for `#import <spec> from "path"`.
261#[derive(Debug, PartialEq, Clone)]
262pub enum DirectiveImportSpec {
263    /// `#import name from "path"` — bind module under `name`.
264    Alias(String),
265    /// `#import * from "path"` — spread module's exported bindings.
266    Spread,
267    /// `#import { a, b as c } from "path"` — bind each entry; `Some(_)`
268    /// is the rebinding alias.
269    Destructure(Vec<(String, Option<String>)>),
270}
271
272/// One `<type> <ident>` parameter of a `#main(...)` signature.
273#[derive(Debug, PartialEq, Clone)]
274pub struct DirectiveMainParam {
275    pub name: String,
276    pub name_range: TokenRange,
277    pub type_node: TypeNode,
278}
279
280/// One typed parameter of a schema method declared inside `with { ... }`.
281/// Form: `<ident>: <TypeNode>`. The `self` receiver is implicit and is not
282/// represented here; analyzer-side lowering injects it as a leading
283/// parameter of type `Self`.
284#[derive(Debug, PartialEq, Clone)]
285pub struct SchemaMethodParam {
286    pub name: String,
287    pub name_range: TokenRange,
288    pub type_node: TypeNode,
289}
290
291/// A method declaration inside a schema's `with { ... }` block.
292///
293/// Source form: `[#derive C ...]* [#native] name(p1: T1, ...) -> R [: body]`
294/// — the body is required when `is_native` is false and forbidden when it
295/// is true (parser enforces this).
296#[derive(Debug, PartialEq, Clone)]
297pub struct SchemaMethod {
298    /// Method name (the `name` in `name(...) -> R`).
299    pub name: String,
300    pub name_range: TokenRange,
301    /// Method-level generic type parameter names (e.g. `["U"]` for
302    /// `map<U>(...)`). Empty for monomorphic methods. Each occurrence
303    /// inside `params[i].type_node` or `return_type` is a placeholder
304    /// instantiated at the call site, on top of any schema-level
305    /// placeholders already in scope.
306    pub generics: Vec<String>,
307    /// Typed parameters as written; `self` is implicit and is added by
308    /// the analyzer when lowering.
309    pub params: Vec<SchemaMethodParam>,
310    /// Return type (the `R` in `-> R`). Required for every method —
311    /// methods are not type-inferred at the signature level.
312    pub return_type: TypeNode,
313    /// Body expression (`: body`). `None` when the method is marked
314    /// `#native` — the host registers the implementation.
315    pub body: Option<Box<Node>>,
316    /// Constraint names from method-level `#derive <Constraint>` pragmas,
317    /// in source order.
318    pub derives: Vec<String>,
319    /// True when an `#native` pragma precedes this method, indicating
320    /// the body lives in host Rust (registered via the schema-method
321    /// host API). The parser leaves `body` `None` in this case.
322    pub is_native: bool,
323    /// True when a `#internal` pragma precedes this method. Internal
324    /// methods are visible only from other method bodies on the same
325    /// schema; script-level `value.method()` calls fail with
326    /// `MethodNotFound` at the analyzer stage.
327    pub is_private: bool,
328    pub range: TokenRange,
329    pub doc_comment: Option<String>,
330}
331
332/// Parsed `#name ...` directive — a structural / declarative attribute
333/// stacked above a node. Parallel to [`Decorator`] but with one of five
334/// fixed [`DirectiveShape`]s rather than free-form `args`.
335#[derive(Debug, PartialEq, Clone)]
336pub struct Directive {
337    /// Single-segment directive name (e.g. `"main"`, `"schema"`).
338    pub name: String,
339    /// Parsed body matching the shape registered for `name`.
340    pub body: DirectiveBody,
341    /// Source range of the entire `#name ...` form.
342    pub range: TokenRange,
343}
344
345#[derive(Debug, PartialEq, Clone)]
346pub struct TypeNode {
347    pub path: Vec<String>,
348    pub generics: Vec<TypeNode>,
349    pub is_optional: bool,
350    pub range: TokenRange,
351    /// `Some(_)` only when this node is an alternative inside the lowered
352    /// internal representation of `#enum`. `Some(vec![])` is a unit variant;
353    /// `Some(non-empty)` carries the variant payload as `(field_name, field_type)`
354    /// pairs. Stays `None` for every non-variant type expression.
355    pub variant_fields: Option<Vec<(String, TypeNode)>>,
356    /// Documentation extracted from leading comments.
357    pub doc_comment: Option<String>,
358}
359
360#[derive(Debug, PartialEq, Clone)]
361pub struct ClosureParam {
362    pub name: String,
363    pub type_hint: Option<TypeNode>,
364    pub range: TokenRange,
365}
366
367#[derive(Debug, PartialEq, Clone)]
368pub struct PatternBinding {
369    /// `Some(field)` for struct payload patterns (`Email { address: a }`),
370    /// `None` for tuple payload patterns (`Pair(a, b)`).
371    pub field: Option<String>,
372    /// Bound local name. `None` means the payload slot is ignored (`*`).
373    pub binding: Option<String>,
374}
375
376#[derive(Debug, Clone)]
377pub struct Node {
378    /// Stable identity assigned at construction. Analyzer side-tables key
379    /// off this; not part of structural equality.
380    pub id: NodeId,
381    /// `Arc` rather than `Box` so analyzer side-tables (`node_index`) and
382    /// every walker that snapshots a `Node` share the body via reference
383    /// counting instead of recursively deep-cloning the subtree. The AST
384    /// is effectively immutable after parsing; the lone in-place rewrite
385    /// (closure desugar in `lower.rs`) reassigns the field on a freshly
386    /// built node before any shared clones exist.
387    pub expr: Arc<Expr>,
388    /// `@name(...)` decorators stacked above this node — value-transform
389    /// hooks (host-registered + user-definable).
390    pub decorators: Vec<Decorator>,
391    /// `#name ...` directives stacked above this node — structural /
392    /// declarative attributes (host-registered only). Parsed in source
393    /// order; the analyzer interprets them by name + shape.
394    pub directives: Vec<Directive>,
395    pub type_hint: Option<TypeNode>,
396    pub range: TokenRange,
397    /// Documentation extracted from leading comments immediately preceding
398    /// the node.
399    pub doc_comment: Option<String>,
400}
401
402/// Structural equality only — `id` is intentionally excluded so two
403/// independently-parsed-but-identical AST fragments still compare equal.
404/// This matters for `Value::Closure` PartialEq (compares `body: Node`) and
405/// for parser tests that round-trip syntactic shape.
406impl PartialEq for Node {
407    fn eq(&self, other: &Self) -> bool {
408        self.expr == other.expr
409            && self.decorators == other.decorators
410            && self.directives == other.directives
411            && self.type_hint == other.type_hint
412            && self.range == other.range
413            && self.doc_comment == other.doc_comment
414    }
415}
416
417impl Node {
418    pub fn new(expr: Expr, range: TokenRange) -> Self {
419        Self {
420            id: NodeId::alloc(),
421            expr: Arc::new(expr),
422            decorators: Vec::new(),
423            directives: Vec::new(),
424            type_hint: None,
425            range,
426            doc_comment: None,
427        }
428    }
429
430    /// Construct a `Node` with a caller-supplied `NodeId`. Used by tests
431    /// and (rarely) by AST rewriters that want to preserve the original
432    /// node's identity after a structural transform.
433    pub fn with_id(id: NodeId, expr: Expr, range: TokenRange) -> Self {
434        Self {
435            id,
436            expr: Arc::new(expr),
437            decorators: Vec::new(),
438            directives: Vec::new(),
439            type_hint: None,
440            range,
441            doc_comment: None,
442        }
443    }
444
445    pub fn with_decorators(mut self, decorators: Vec<Decorator>) -> Self {
446        self.decorators = decorators;
447        self
448    }
449
450    pub fn with_directives(mut self, directives: Vec<Directive>) -> Self {
451        self.directives = directives;
452        self
453    }
454
455    pub fn with_type_hint(mut self, type_hint: Option<TypeNode>) -> Self {
456        self.type_hint = type_hint;
457        self
458    }
459
460    pub fn with_doc_comment(mut self, doc_comment: Option<String>) -> Self {
461        self.doc_comment = doc_comment;
462        self
463    }
464}
465
466#[derive(Debug, PartialEq, Clone)]
467pub enum Expr {
468    /// Internal placeholder for parse recovery or removed literals.
469    Missing,
470    Bool(bool),
471    Int(i64),
472    Float(OrderedFloat<f64>),
473    String(String),
474
475    List(Vec<Node>),
476    /// Fixed-arity, heterogeneous, positional tuple value `(e1, e2, ...)`.
477    /// Distinct from `List` so the analyzer can type it position-by-position
478    /// (heterogeneous elements allowed, unlike a list literal) and the
479    /// evaluator can preserve that distinction as `Value::Tuple`. JSON
480    /// output still projects it as a positional array.
481    /// `Tuple(vec![])` is the unit / zero-tuple `()`.
482    Tuple(Vec<Node>),
483    Dict(Vec<(TokenKey, Node)>),
484
485    Spread(Node),
486
487    Comprehension {
488        element: Node,
489        id: String,
490        iterable: Node,
491        condition: Option<Node>,
492    },
493
494    Variable(Vec<TokenKey>),
495    Reference {
496        base: RefBase,
497        path: Vec<TokenKey>,
498    },
499
500    Binary(Operator, Node, Node),
501    Unary(Operator, Node),
502    Ternary {
503        cond: Node,
504        then: Node,
505        els: Node,
506    },
507
508    FnCall {
509        path: Vec<TokenKey>,
510        args: Vec<CallArg>,
511    },
512
513    FString(Vec<FStringPart>),
514
515    Type(TypeNode),
516
517    Wildcard,
518
519    Where {
520        expr: Node,
521        bindings: Node,
522    },
523
524    Match {
525        expr: Node,
526        arms: Vec<(Node, Node)>,
527    },
528
529    VariantPattern {
530        enum_path: Vec<String>,
531        variant: String,
532        bindings: Vec<PatternBinding>,
533    },
534
535    Closure {
536        params: Vec<ClosureParam>,
537        return_type: Option<TypeNode>,
538        body: Node,
539    },
540
541    /// Tagged-enum variant constructor: `EnumName.VariantName { field: value, ... }`.
542    /// Unit variants share the bare-identifier-path form parsed as `Variable`
543    /// — the evaluator promotes them to a variant when the head resolves to
544    /// a sum-type schema.
545    VariantCtor {
546        enum_path: Vec<String>,
547        variant: String,
548        body: Node,
549    },
550}
551#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)]
552pub enum RefBase {
553    Root,
554    Sibling,
555    Uncle,
556    Prev,
557    Next,
558    Index,
559    This,
560}
561
562#[derive(Debug, PartialEq, Clone)]
563pub enum FStringPart {
564    Literal(String),
565    Interpolation(Box<Node>),
566}
567
568#[derive(Debug, PartialEq, Clone, Copy, Eq, Hash)]
569pub enum Operator {
570    Add,
571    Sub,
572    Mul,
573    Div,
574    Mod,
575    Eq,
576    Ne,
577    Lt,
578    Gt,
579    Le,
580    Ge,
581    And,
582    Or,
583    Not,
584    Pipe,
585    Concat,
586}
587
588impl Expr {
589    /// Stable, allocation-free name for the variant — used by diagnostics
590    /// (`SchemaBodyNotDict { found }`) and any walker that wants a cheap
591    /// dispatch tag without matching the full enum.
592    pub fn kind(&self) -> &'static str {
593        match self {
594            Expr::Missing => "Missing",
595            Expr::Bool(_) => "Bool",
596            Expr::Int(_) => "Int",
597            Expr::Float(_) => "Float",
598            Expr::String(_) => "String",
599            Expr::List(_) => "List",
600            Expr::Tuple(_) => "Tuple",
601            Expr::Dict(_) => "Dict",
602            Expr::Spread(_) => "Spread",
603            Expr::Comprehension { .. } => "Comprehension",
604            Expr::Variable(_) => "Variable",
605            Expr::Reference { .. } => "Reference",
606            Expr::Binary(_, _, _) => "Binary",
607            Expr::Unary(_, _) => "Unary",
608            Expr::Ternary { .. } => "Ternary",
609            Expr::FnCall { .. } => "FnCall",
610            Expr::FString(_) => "FString",
611            Expr::Type(_) => "Type",
612            Expr::Wildcard => "Wildcard",
613            Expr::Where { .. } => "Where",
614            Expr::Match { .. } => "Match",
615            Expr::VariantPattern { .. } => "VariantPattern",
616            Expr::Closure { .. } => "Closure",
617            Expr::VariantCtor { .. } => "VariantCtor",
618        }
619    }
620}
621
622impl Display for Expr {
623    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
624        match self {
625            Expr::Missing => write!(f, "<missing>"),
626            Expr::Bool(v) => write!(f, "{}", v),
627            Expr::Int(v) => write!(f, "{}", v),
628            Expr::Float(v) => write!(f, "{}", v),
629            Expr::String(v) => write!(f, "\"{}\"", v),
630            _ => write!(f, "<expr>"),
631        }
632    }
633}
634
635/// Cheap predicate over primitive/container type identifiers such as `Int`,
636/// `String`, `List`, `Dict`, and `Tuple`. Prelude enum schemas such as `Option`
637/// and `Result` are resolved by the analyzer/evaluator schema paths, not by
638/// this primitive set.
639pub fn is_builtin_type_name(name: &str) -> bool {
640    matches!(
641        name,
642        "Int"
643            | "Float"
644            | "Number"
645            | "String"
646            | "Bool"
647            | "Any"
648            | "List"
649            | "Dict"
650            | "Closure"
651            | "Fn"
652            // v1.7: tuple types `(T1, T2, ...)` are encoded internally
653            // as a single-segment path `Tuple` whose `generics` carry
654            // the element types in order. Reserved as a builtin name
655            // so a user-declared `#schema Tuple { ... }` doesn't
656            // shadow the encoding.
657            | "Tuple"
658    )
659}
660
661/// Lift a decorator-argument [`Expr`] back into a [`TypeNode`].
662///
663/// Used by every site that consumes a `@brand(Type)` argument — the
664/// evaluator's `BrandDecorator::wrap_with_ast` runs this on the live
665/// argument, and the analyzer's schema-field lowering runs it to lift
666/// `@brand(X)` placed on a typeless schema field into an implicit type
667/// prefix.
668///
669/// Accepted shapes:
670///
671/// * Full type expression (`Map<String, Int>`, `Foo<T>`, `Weather?`,
672///   `Int`) — produced by `crate::expr::parse_type_expr`
673///   and surfaced as `Expr::Type`. The contained `TypeNode` is returned
674///   verbatim so generics and `is_optional` survive.
675/// * Bareword / dotted path (`Weather`, `geo.Location`) — surfaced as
676///   `Expr::Variable` because the parser only commits to `Expr::Type`
677///   when it sees generics, `?`, or a known builtin head. Each path
678///   segment must be a simple identifier (no `?.`, `[i]`, or spread).
679/// * String literal (`"Weather"`, `"geo.Location"`) — split on `.` for
680///   parity with the bareword form.
681///
682/// Returns `None` when `expr` is none of the above; callers turn that
683/// into a user-facing "argument must be a type" error.
684pub fn type_node_from_brand_arg(expr: &Expr, range: TokenRange) -> Option<TypeNode> {
685    match expr {
686        Expr::Type(t) => Some(t.clone()),
687        Expr::Variable(path) => {
688            let mut segs = Vec::with_capacity(path.len());
689            for tk in path {
690                match tk {
691                    TokenKey::String(s, _, false) => segs.push(s.clone()),
692                    _ => return None,
693                }
694            }
695            if segs.is_empty() {
696                return None;
697            }
698            Some(TypeNode {
699                path: segs,
700                generics: Vec::new(),
701                is_optional: false,
702                range,
703                variant_fields: None,
704                doc_comment: None,
705            })
706        }
707        Expr::String(s) => {
708            if s.is_empty() {
709                return None;
710            }
711            let segs: Vec<String> = s.split('.').map(|p| p.to_string()).collect();
712            if segs.iter().any(|p| p.is_empty()) {
713                return None;
714            }
715            Some(TypeNode {
716                path: segs,
717                generics: Vec::new(),
718                is_optional: false,
719                range,
720                variant_fields: None,
721                doc_comment: None,
722            })
723        }
724        _ => None,
725    }
726}