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}