Skip to main content

mii_http/parse/
spec.rs

1//! Parser for the .http specs DSL.
2//!
3//! The grammar is line-oriented. Setup directives precede the first endpoint
4//! (whose first line is `METHOD /path`). Block bodies (`BODY form { ... }`,
5//! `BODY json { ... }`) span multiple lines and are closed by a `}` on its own
6//! line.
7//!
8//! Whole-line comments start with `#`. Trailing inline comments are not
9//! supported (to avoid ambiguity with regex/exec content).
10//!
11//! The Exec sub-language is parsed by [`crate::parse::exec`] (chumsky); this
12//! module only handles the line-oriented outer grammar.
13
14use crate::diag::Diag;
15use crate::spec::*;
16
17pub struct ParseResult {
18    pub spec: Option<Spec>,
19    pub diags: Vec<Diag>,
20}
21
22pub fn parse(source: &str) -> ParseResult {
23    tracing::debug!(bytes = source.len(), "parse::parse");
24    let mut p = Parser::new(source);
25    let spec = p.parse_spec();
26    let diag_count = p.diags.len();
27    let endpoint_count = spec.as_ref().map(|s| s.endpoints.len()).unwrap_or(0);
28    tracing::debug!(
29        endpoints = endpoint_count,
30        diags = diag_count,
31        "parse::parse done"
32    );
33    ParseResult {
34        spec,
35        diags: p.diags,
36    }
37}
38
39struct Parser<'a> {
40    /// (line text without trailing newline, absolute byte offset of line start)
41    lines: Vec<(&'a str, usize)>,
42    cursor: usize,
43    diags: Vec<Diag>,
44}
45
46impl<'a> Parser<'a> {
47    fn new(src: &'a str) -> Self {
48        let mut lines = Vec::new();
49        let mut offset = 0usize;
50        for line in src.split_inclusive('\n') {
51            let trimmed = line.strip_suffix('\n').unwrap_or(line);
52            let trimmed = trimmed.strip_suffix('\r').unwrap_or(trimmed);
53            lines.push((trimmed, offset));
54            offset += line.len();
55        }
56        Self {
57            lines,
58            cursor: 0,
59            diags: Vec::new(),
60        }
61    }
62
63    fn err(&mut self, msg: impl Into<String>, span: Span, label: impl Into<String>) {
64        self.diags.push(Diag::error(msg, span, label));
65    }
66
67    fn peek(&self) -> Option<(&'a str, usize)> {
68        self.lines.get(self.cursor).copied()
69    }
70
71    fn advance(&mut self) -> Option<(&'a str, usize)> {
72        let item = self.peek();
73        if item.is_some() {
74            self.cursor += 1;
75        }
76        item
77    }
78
79    fn skip_blank_and_comments(&mut self) {
80        while let Some((text, _)) = self.peek() {
81            let t = text.trim_start();
82            if t.is_empty() || t.starts_with('#') {
83                self.cursor += 1;
84            } else {
85                break;
86            }
87        }
88    }
89
90    fn parse_spec(&mut self) -> Option<Spec> {
91        let setup_start = self.peek().map(|(_, o)| o).unwrap_or(0);
92        let setup = self.parse_setup(setup_start);
93        let mut endpoints = Vec::new();
94        loop {
95            self.skip_blank_and_comments();
96            if self.peek().is_none() {
97                break;
98            }
99            if let Some(ep) = self.parse_endpoint() {
100                endpoints.push(ep);
101            } else {
102                // give up if we couldn't parse, advance one to avoid infinite loop
103                if self.advance().is_none() {
104                    break;
105                }
106            }
107        }
108        Some(Spec { setup, endpoints })
109    }
110
111    fn parse_setup(&mut self, start: usize) -> Setup {
112        let mut setup = Setup {
113            span: start..start,
114            ..Setup::default()
115        };
116        loop {
117            self.skip_blank_and_comments();
118            let Some((text, offset)) = self.peek() else {
119                break;
120            };
121            let trimmed = text.trim_start();
122            // detect endpoint method line
123            let upper_first = trimmed.split_whitespace().next().unwrap_or("");
124            if matches!(upper_first, "GET" | "POST" | "PUT" | "DELETE" | "PATCH") {
125                break;
126            }
127            // consume directive line
128            self.cursor += 1;
129            self.parse_setup_directive(&mut setup, text, offset);
130            setup.span.end = offset + text.len();
131        }
132        setup
133    }
134
135    fn parse_setup_directive(&mut self, setup: &mut Setup, text: &str, offset: usize) {
136        let leading_ws = text.len() - text.trim_start().len();
137        let body = text.trim_start();
138        let (key, rest) = split_first_word(body);
139        let key_span = (offset + leading_ws)..(offset + leading_ws + key.len());
140        let rest_offset = offset + leading_ws + key.len();
141        let rest_trim_off = rest.len() - rest.trim_start().len();
142        let value = rest.trim();
143        let value_offset = rest_offset + rest_trim_off;
144        let value_span = value_offset..(value_offset + value.len());
145        match key {
146            "VERSION" => match value.parse::<u32>() {
147                Ok(v) => setup.version = Some(v),
148                Err(_) => self.err("invalid VERSION", value_span, "expected positive integer"),
149            },
150            "BASE" => {
151                if value.is_empty() {
152                    self.err("missing BASE value", key_span, "expected a path like /api");
153                } else {
154                    let mut v = value.to_string();
155                    if !v.starts_with('/') {
156                        v.insert(0, '/');
157                    }
158                    setup.base = Some(v.trim_end_matches('/').to_string());
159                }
160            }
161            "AUTH" => match parse_auth(value, value_offset) {
162                Ok(a) => setup.auth = Some(a),
163                Err(d) => self.diags.push(d),
164            },
165            "JWT_VERIFIER" => match parse_value_source(value, value_offset) {
166                Ok(s) => setup.jwt_verifier = Some(s),
167                Err(d) => self.diags.push(d),
168            },
169            "TOKEN_SECRET" => match parse_value_source(value, value_offset) {
170                Ok(s) => setup.token_secret = Some(s),
171                Err(d) => self.diags.push(d),
172            },
173            "MAX_BODY_SIZE" => match parse_size(value) {
174                Some(n) => setup.max_body_size = Some(n),
175                None => self.err(
176                    "invalid MAX_BODY_SIZE",
177                    value_span,
178                    "expected e.g. 1mb, 512kb, 1024",
179                ),
180            },
181            "MAX_QUERY_PARAM_SIZE" => match value.parse::<u64>() {
182                Ok(n) => setup.max_query_param_size = Some(n),
183                Err(_) => self.err(
184                    "invalid MAX_QUERY_PARAM_SIZE",
185                    value_span,
186                    "expected integer",
187                ),
188            },
189            "MAX_HEADER_SIZE" => match value.parse::<u64>() {
190                Ok(n) => setup.max_header_size = Some(n),
191                Err(_) => self.err("invalid MAX_HEADER_SIZE", value_span, "expected integer"),
192            },
193            "TIMEOUT" => match parse_duration_ms(value) {
194                Some(n) => setup.timeout_ms = Some(n),
195                None => self.err(
196                    "invalid TIMEOUT",
197                    value_span,
198                    "expected e.g. 30s, 500ms, 1m",
199                ),
200            },
201            other => {
202                self.err(
203                    format!("unknown setup directive `{}`", other),
204                    key_span,
205                    "expected one of VERSION, BASE, AUTH, JWT_VERIFIER, TOKEN_SECRET, MAX_BODY_SIZE, MAX_QUERY_PARAM_SIZE, MAX_HEADER_SIZE, TIMEOUT",
206                );
207            }
208        }
209    }
210
211    fn parse_endpoint(&mut self) -> Option<Endpoint> {
212        let (text, offset) = self.advance()?;
213        let trimmed = text.trim_start();
214        let leading = text.len() - trimmed.len();
215        let (method_str, rest) = split_first_word(trimmed);
216        let method = match method_str {
217            "GET" => Method::Get,
218            "POST" => Method::Post,
219            "PUT" => Method::Put,
220            "DELETE" => Method::Delete,
221            "PATCH" => Method::Patch,
222            other => {
223                self.err(
224                    format!("expected HTTP method, found `{}`", other),
225                    (offset + leading)..(offset + leading + method_str.len()),
226                    "expected GET/POST/PUT/DELETE/PATCH",
227                );
228                return None;
229            }
230        };
231        let path_off = offset + leading + method_str.len() + (rest.len() - rest.trim_start().len());
232        let path_str = rest.trim().to_string();
233        let path_span = path_off..(path_off + path_str.len());
234        let path_segments = self.parse_path(&path_str, path_off);
235        let header_span = (offset + leading)..(offset + text.len());
236        let mut endpoint = Endpoint {
237            method,
238            path: path_str,
239            path_segments,
240            response_type: None,
241            query_params: Vec::new(),
242            headers: Vec::new(),
243            vars: Vec::new(),
244            body: None,
245            exec: ExecSpec {
246                raw: String::new(),
247                span: 0..0,
248                pipeline: Vec::new(),
249            },
250            span: header_span,
251        };
252        let _ = path_span;
253
254        loop {
255            self.skip_blank_and_comments();
256            let Some((line_text, line_off)) = self.peek() else {
257                break;
258            };
259            let t = line_text.trim_start();
260            let first_word = t.split_whitespace().next().unwrap_or("");
261            if matches!(first_word, "GET" | "POST" | "PUT" | "DELETE" | "PATCH") {
262                break;
263            }
264            self.cursor += 1;
265            let is_exec = self.parse_endpoint_directive(&mut endpoint, line_text, line_off);
266            endpoint.span.end = line_off + line_text.len();
267            // Exec terminates the endpoint: nothing after `Exec:` belongs to it.
268            if is_exec {
269                break;
270            }
271        }
272        if endpoint.exec.raw.is_empty() {
273            self.err(
274                "endpoint missing Exec directive",
275                endpoint.span.clone(),
276                "every endpoint requires an `Exec:` line",
277            );
278        }
279        Some(endpoint)
280    }
281
282    fn parse_endpoint_directive(&mut self, ep: &mut Endpoint, text: &str, offset: usize) -> bool {
283        let leading = text.len() - text.trim_start().len();
284        let body = text.trim_start();
285
286        // Some directives are case-sensitive and use `:` separators (Response-Type, Exec).
287        // Others use space separators (QUERY, HEADER, VAR, BODY).
288        if let Some(rest) = body.strip_prefix("Response-Type") {
289            let rest = rest.trim_start_matches([':', ' ', '\t']);
290            ep.response_type = Some(rest.trim().to_string());
291            return false;
292        }
293        if let Some(rest) = body.strip_prefix("Exec:") {
294            let exec_off = offset + leading + "Exec:".len();
295            let trim_off = rest.len() - rest.trim_start().len();
296            let raw = rest.trim().to_string();
297            let span = (exec_off + trim_off)..(exec_off + trim_off + raw.len());
298            let pipeline = match crate::parse::exec::parse_exec(&raw, span.start) {
299                Ok(p) => p,
300                Err(d) => {
301                    self.diags.push(d);
302                    Vec::new()
303                }
304            };
305            ep.exec = ExecSpec {
306                raw,
307                span,
308                pipeline,
309            };
310            ep.span.end = offset + text.len();
311            return true;
312        }
313
314        let (key, rest) = split_first_word(body);
315        let key_off = offset + leading;
316        let rest_off = key_off + key.len();
317        let rest_trim_off = rest.len() - rest.trim_start().len();
318        let value = rest.trim();
319        let val_off = rest_off + rest_trim_off;
320
321        match key {
322            "QUERY" => match self.parse_named_field(value, val_off) {
323                Ok(f) => ep.query_params.push(f),
324                Err(d) => self.diags.push(d),
325            },
326            "HEADER" => match self.parse_named_field(value, val_off) {
327                Ok(f) => ep.headers.push(f),
328                Err(d) => self.diags.push(d),
329            },
330            "VAR" => match self.parse_var_def(value, val_off) {
331                Ok(v) => ep.vars.push(v),
332                Err(d) => self.diags.push(d),
333            },
334            "BODY" => self.parse_body(ep, value, val_off),
335            other => self.err(
336                format!("unknown directive `{}`", other),
337                key_off..key_off + key.len(),
338                "expected QUERY, HEADER, VAR, BODY, Response-Type or Exec",
339            ),
340        }
341        false
342    }
343
344    fn parse_path(&mut self, path: &str, offset: usize) -> Vec<PathSegment> {
345        let mut segs = Vec::new();
346        if !path.starts_with('/') {
347            self.err(
348                "path must start with `/`",
349                offset..(offset + path.len()),
350                "add a leading slash",
351            );
352        }
353        for (idx, raw) in path.split('/').enumerate() {
354            if idx == 0 {
355                continue;
356            }
357            // compute span of this segment
358            // (rough; sufficient for diagnostics)
359            let local_off = offset
360                + path
361                    .match_indices('/')
362                    .nth(idx - 1)
363                    .map(|(i, _)| i + 1)
364                    .unwrap_or(0);
365            let seg_span = local_off..(local_off + raw.len());
366            if raw.is_empty() {
367                continue;
368            }
369            if let Some(rest) = raw.strip_prefix(':') {
370                let mut parts = rest.splitn(2, ':');
371                let name = parts.next().unwrap_or("").to_string();
372                let ty_str = parts.next().unwrap_or("string");
373                if name.is_empty() {
374                    self.err(
375                        "empty path parameter name",
376                        seg_span.clone(),
377                        "use `:name:type`",
378                    );
379                    continue;
380                }
381                let ty = match parse_type_expr(ty_str, seg_span.end - ty_str.len()) {
382                    Ok(t) => t,
383                    Err(d) => {
384                        self.diags.push(d);
385                        TypeExpr::String
386                    }
387                };
388                segs.push(PathSegment::Param {
389                    name,
390                    ty,
391                    span: seg_span,
392                });
393            } else {
394                segs.push(PathSegment::Literal(raw.to_string()));
395            }
396        }
397        segs
398    }
399
400    fn parse_named_field(&mut self, value: &str, offset: usize) -> Result<NamedField, Diag> {
401        // syntax: name[?]: <type>
402        let head = split_field_head(value, offset)?;
403        let ty = parse_type_expr(head.tail, head.tail_off)?;
404        Ok(NamedField {
405            name: head.name,
406            optional: head.optional,
407            ty,
408            span: offset..(offset + value.len()),
409        })
410    }
411
412    fn parse_var_def(&mut self, value: &str, offset: usize) -> Result<VarDef, Diag> {
413        // syntax: VAR name <source>
414        let (name, rest) = split_first_word(value);
415        if name.is_empty() {
416            return Err(Diag::error(
417                "missing var name",
418                offset..offset + value.len(),
419                "expected `VAR name <source>`",
420            ));
421        }
422        let rest_trim_off = rest.len() - rest.trim_start().len();
423        let src_str = rest.trim();
424        let src_off = offset + name.len() + rest_trim_off;
425        let source = parse_value_source(src_str, src_off)?;
426        Ok(VarDef {
427            name: name.to_string(),
428            source,
429            span: offset..(offset + value.len()),
430        })
431    }
432
433    fn parse_body(&mut self, ep: &mut Endpoint, value: &str, offset: usize) {
434        // Cases:
435        //   BODY string
436        //   BODY json
437        //   BODY binary
438        //   BODY json { ... }
439        //   BODY form { ... }
440        let (kind, rest) = split_first_word(value);
441        let kind_span = offset..(offset + kind.len());
442        let rest_trim = rest.trim();
443        let opens_block = rest_trim.starts_with('{');
444        match kind {
445            "string" => {
446                if opens_block {
447                    self.err("BODY string takes no schema", kind_span.clone(), "");
448                }
449                ep.body = Some(BodySpec::String { span: kind_span });
450            }
451            "binary" => {
452                if opens_block {
453                    self.err("BODY binary takes no schema", kind_span.clone(), "");
454                }
455                ep.body = Some(BodySpec::Binary { span: kind_span });
456            }
457            "json" => {
458                if !opens_block {
459                    ep.body = Some(BodySpec::Json {
460                        schema: None,
461                        span: kind_span,
462                    });
463                } else {
464                    let fields = self.parse_json_block();
465                    ep.body = Some(BodySpec::Json {
466                        schema: Some(JsonSchema { fields }),
467                        span: kind_span,
468                    });
469                }
470            }
471            "form" => {
472                if !opens_block {
473                    self.err(
474                        "BODY form requires `{ ... }` schema",
475                        kind_span.clone(),
476                        "add a `{` block listing form fields",
477                    );
478                    ep.body = Some(BodySpec::Form {
479                        fields: Vec::new(),
480                        span: kind_span,
481                    });
482                } else {
483                    let fields = self.parse_form_block();
484                    ep.body = Some(BodySpec::Form {
485                        fields,
486                        span: kind_span,
487                    });
488                }
489            }
490            other => self.err(
491                format!("unknown body kind `{}`", other),
492                kind_span,
493                "expected one of: string, json, form, binary",
494            ),
495        }
496    }
497
498    fn parse_form_block(&mut self) -> Vec<NamedField> {
499        self.parse_brace_block("BODY form", |this, val, off| {
500            this.parse_named_field(val, off)
501        })
502    }
503
504    fn parse_json_block(&mut self) -> Vec<JsonField> {
505        self.parse_brace_block("BODY json", |this, val, off| {
506            this.parse_json_field(val, off)
507        })
508    }
509
510    /// Generic `{ ... }` block parser used by `BODY form` and `BODY json`.
511    /// Lines are stripped of trailing commas and dispatched to `line_parser`.
512    /// Errors from the line parser are recorded as diagnostics; the block ends
513    /// at the line containing only `}`.
514    fn parse_brace_block<T, F>(&mut self, label: &str, mut line_parser: F) -> Vec<T>
515    where
516        F: FnMut(&mut Self, &str, usize) -> Result<T, Diag>,
517    {
518        let mut out = Vec::new();
519        loop {
520            self.skip_blank_and_comments();
521            let Some((text, off)) = self.peek() else {
522                self.err(format!("unterminated {} block", label), 0..0, "missing `}`");
523                break;
524            };
525            let t = text.trim();
526            if t == "}" {
527                self.cursor += 1;
528                break;
529            }
530            self.cursor += 1;
531            let leading = text.len() - text.trim_start().len();
532            let val = t.trim_end_matches(',').trim();
533            match line_parser(self, val, off + leading) {
534                Ok(f) => out.push(f),
535                Err(d) => self.diags.push(d),
536            }
537        }
538        out
539    }
540
541    fn parse_json_field(&mut self, value: &str, offset: usize) -> Result<JsonField, Diag> {
542        let head = split_field_head(value, offset)?;
543        let ty = if let Some(inner) = head
544            .tail
545            .strip_prefix('[')
546            .and_then(|s| s.strip_suffix(']'))
547        {
548            JsonFieldType::Array(parse_type_expr(inner.trim(), head.tail_off + 1)?)
549        } else {
550            JsonFieldType::Scalar(parse_type_expr(head.tail, head.tail_off)?)
551        };
552        Ok(JsonField {
553            name: head.name,
554            optional: head.optional,
555            ty,
556            span: offset..(offset + value.len()),
557        })
558    }
559}
560
561/// `name[?]: <rest>` decomposition shared by `parse_named_field` and
562/// `parse_json_field`. Returns the trimmed name, optionality, the substring
563/// after the colon, and that substring's absolute byte offset.
564struct FieldHead<'a> {
565    name: String,
566    optional: bool,
567    tail: &'a str,
568    tail_off: usize,
569}
570
571fn split_field_head(value: &str, offset: usize) -> Result<FieldHead<'_>, Diag> {
572    let colon_pos = value.find(':').ok_or_else(|| {
573        Diag::error(
574            "missing `:` in field declaration",
575            offset..offset + value.len(),
576            "expected `name: <type>`",
577        )
578    })?;
579    let head = &value[..colon_pos];
580    let after = &value[colon_pos + 1..];
581    let tail = after.trim_start();
582    let tail_off = offset + colon_pos + 1 + (after.len() - tail.len());
583    let (name, optional) = if let Some(stripped) = head.strip_suffix('?') {
584        (stripped.trim().to_string(), true)
585    } else {
586        (head.trim().to_string(), false)
587    };
588    if name.is_empty() {
589        return Err(Diag::error(
590            "empty field name",
591            offset..offset + value.len(),
592            "expected a name before `:`",
593        ));
594    }
595    Ok(FieldHead {
596        name,
597        optional,
598        tail,
599        tail_off,
600    })
601}
602
603fn split_first_word(s: &str) -> (&str, &str) {
604    let s = s.trim_start();
605    let end = s.find(|c: char| c.is_whitespace()).unwrap_or(s.len());
606    (&s[..end], &s[end..])
607}
608
609fn parse_value_source(s: &str, offset: usize) -> Result<ValueSource, Diag> {
610    let s = s.trim();
611    if let Some(inner) = s.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
612        let inner = inner.trim();
613        let (kind, rest) = split_first_word(inner);
614        let rest = rest.trim();
615        match kind {
616            "ENV" => Ok(ValueSource::Env {
617                name: rest.to_string(),
618                span: offset..offset + s.len(),
619            }),
620            "HEADER" => Ok(ValueSource::Header {
621                name: rest.to_string(),
622                span: offset..offset + s.len(),
623            }),
624            other => Err(Diag::error(
625                format!("unknown value source `{}`", other),
626                offset..offset + s.len(),
627                "expected [ENV NAME] or [HEADER NAME]",
628            )),
629        }
630    } else if !s.is_empty() {
631        Ok(ValueSource::Literal {
632            value: s.to_string(),
633            span: offset..offset + s.len(),
634        })
635    } else {
636        Err(Diag::error(
637            "missing value source",
638            offset..offset,
639            "expected [ENV NAME], [HEADER NAME] or a literal",
640        ))
641    }
642}
643
644fn parse_auth(value: &str, offset: usize) -> Result<AuthSpec, Diag> {
645    let value = value.trim();
646    let (scheme, rest) = split_first_word(value);
647    let rest = rest.trim();
648    if !scheme.eq_ignore_ascii_case("Bearer") {
649        return Err(Diag::error(
650            format!("unsupported auth scheme `{}`", scheme),
651            offset..offset + scheme.len(),
652            "only Bearer is supported",
653        ));
654    }
655    let inner = rest
656        .strip_prefix('[')
657        .and_then(|s| s.strip_suffix(']'))
658        .ok_or_else(|| {
659            Diag::error(
660                "missing `[HEADER name]` after Bearer",
661                offset..offset + value.len(),
662                "expected `AUTH Bearer [HEADER NAME]`",
663            )
664        })?;
665    let (kind, name) = split_first_word(inner.trim());
666    let name = name.trim();
667    if !kind.eq_ignore_ascii_case("HEADER") {
668        return Err(Diag::error(
669            format!("unsupported auth source `{}`", kind),
670            offset..offset + value.len(),
671            "only [HEADER NAME] is supported",
672        ));
673    }
674    if name.is_empty() {
675        return Err(Diag::error(
676            "missing header name",
677            offset..offset + value.len(),
678            "expected `[HEADER NAME]`",
679        ));
680    }
681    Ok(AuthSpec::BearerHeader {
682        header: name.to_string(),
683        span: offset..offset + value.len(),
684    })
685}
686
687fn parse_size(s: &str) -> Option<u64> {
688    parse_suffixed(
689        s,
690        &[
691            ("kb", 1024),
692            ("mb", 1024 * 1024),
693            ("gb", 1024 * 1024 * 1024),
694            ("b", 1),
695        ],
696        1,
697    )
698}
699
700fn parse_duration_ms(s: &str) -> Option<u64> {
701    parse_suffixed(s, &[("ms", 1), ("s", 1000), ("m", 60_000)], 1000)
702}
703
704/// Strip a known suffix and multiply the leading integer by its weight.
705/// `default_mult` applies when no suffix matches. Suffixes are tried in order,
706/// so list multi-char suffixes (e.g. `"ms"`) before their prefixes (`"s"`).
707fn parse_suffixed(s: &str, suffixes: &[(&str, u64)], default_mult: u64) -> Option<u64> {
708    let s = s.trim().to_ascii_lowercase();
709    let (num, mult) = suffixes
710        .iter()
711        .find_map(|(suf, m)| s.strip_suffix(suf).map(|rest| (rest.trim(), *m)))
712        .unwrap_or((s.as_str(), default_mult));
713    num.trim().parse::<u64>().ok()?.checked_mul(mult)
714}
715
716pub fn parse_type_expr(s: &str, offset: usize) -> Result<TypeExpr, Diag> {
717    let s = s.trim();
718    if s.is_empty() {
719        return Err(Diag::error(
720            "missing type",
721            offset..offset,
722            "expected a type expression",
723        ));
724    }
725    // regex
726    if let Some(stripped) = s.strip_prefix('/') {
727        if let Some(pat) = stripped.strip_suffix('/') {
728            return Ok(TypeExpr::Regex {
729                pattern: pat.to_string(),
730                span: offset..offset + s.len(),
731            });
732        } else {
733            return Err(Diag::error(
734                "unterminated regex",
735                offset..offset + s.len(),
736                "regex must be enclosed in `/.../`",
737            ));
738        }
739    }
740    // int range / float range
741    if let Some(rest) = s.strip_prefix("int(")
742        && let Some(inner) = rest.strip_suffix(')')
743    {
744        let parts: Vec<&str> = inner.splitn(2, "..").collect();
745        if parts.len() == 2
746            && let (Ok(a), Ok(b)) = (
747                parts[0].trim().parse::<i64>(),
748                parts[1].trim().parse::<i64>(),
749            )
750        {
751            return Ok(TypeExpr::IntRange {
752                min: a,
753                max: b,
754                span: offset..offset + s.len(),
755            });
756        }
757        return Err(Diag::error(
758            "invalid int range",
759            offset..offset + s.len(),
760            "expected `int(a..b)`",
761        ));
762    }
763    if let Some(rest) = s.strip_prefix("float(")
764        && let Some(inner) = rest.strip_suffix(')')
765    {
766        let parts: Vec<&str> = inner.splitn(2, "..").collect();
767        if parts.len() == 2
768            && let (Ok(a), Ok(b)) = (
769                parts[0].trim().parse::<f64>(),
770                parts[1].trim().parse::<f64>(),
771            )
772        {
773            return Ok(TypeExpr::FloatRange {
774                min: a,
775                max: b,
776                span: offset..offset + s.len(),
777            });
778        }
779        return Err(Diag::error(
780            "invalid float range",
781            offset..offset + s.len(),
782            "expected `float(a..b)`",
783        ));
784    }
785    match s {
786        "int" => Ok(TypeExpr::Int),
787        "float" => Ok(TypeExpr::Float),
788        "boolean" | "bool" => Ok(TypeExpr::Boolean),
789        "uuid" => Ok(TypeExpr::Uuid),
790        "string" => Ok(TypeExpr::String),
791        "json" => Ok(TypeExpr::Json),
792        "binary" => Ok(TypeExpr::Binary),
793        _ if s.contains('|') => {
794            let variants: Vec<String> = s
795                .split('|')
796                .map(|v| v.trim().to_string())
797                .filter(|v| !v.is_empty())
798                .collect();
799            if variants.is_empty() {
800                Err(Diag::error(
801                    "empty union",
802                    offset..offset + s.len(),
803                    "expected at least one variant",
804                ))
805            } else {
806                Ok(TypeExpr::Union {
807                    variants,
808                    span: offset..offset + s.len(),
809                })
810            }
811        }
812        other => Err(Diag::error(
813            format!("unknown type `{}`", other),
814            offset..offset + s.len(),
815            "expected int, float, boolean, uuid, string, json, binary, a range, union or regex",
816        )),
817    }
818}