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