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            response_stream: false,
242            query_params: Vec::new(),
243            headers: Vec::new(),
244            vars: Vec::new(),
245            body: None,
246            exec: ExecSpec {
247                raw: String::new(),
248                span: 0..0,
249                statements: Vec::new(),
250            },
251            span: header_span,
252        };
253        let _ = path_span;
254
255        loop {
256            self.skip_blank_and_comments();
257            let Some((line_text, line_off)) = self.peek() else {
258                break;
259            };
260            let t = line_text.trim_start();
261            let first_word = t.split_whitespace().next().unwrap_or("");
262            if matches!(first_word, "GET" | "POST" | "PUT" | "DELETE" | "PATCH") {
263                break;
264            }
265            self.cursor += 1;
266            let is_exec = self.parse_endpoint_directive(&mut endpoint, line_text, line_off);
267            endpoint.span.end = line_off + line_text.len();
268            // Exec terminates the endpoint: nothing after `Exec:` belongs to it.
269            if is_exec {
270                break;
271            }
272        }
273        if endpoint.exec.statements.is_empty() {
274            self.err(
275                "endpoint missing Exec directive",
276                endpoint.span.clone(),
277                "every endpoint requires an `Exec:` line",
278            );
279        }
280        Some(endpoint)
281    }
282
283    fn parse_endpoint_directive(&mut self, ep: &mut Endpoint, text: &str, offset: usize) -> bool {
284        let leading = text.len() - text.trim_start().len();
285        let body = text.trim_start();
286
287        // Some directives are case-sensitive and use `:` separators (Response-Type, Exec).
288        // Others use space separators (QUERY, HEADER, VAR, BODY).
289        if let Some(rest) = body.strip_prefix("Response-Type") {
290            let rest = rest.trim_start_matches([':', ' ', '\t']);
291            let trimmed = rest.trim();
292            let (stream, ty) = if let Some(after) = trimmed.strip_prefix("stream") {
293                if after.is_empty() || after.starts_with(|c: char| c.is_whitespace()) {
294                    (true, after.trim().to_string())
295                } else {
296                    (false, trimmed.to_string())
297                }
298            } else {
299                (false, trimmed.to_string())
300            };
301            ep.response_stream = stream;
302            ep.response_type = Some(ty);
303            return false;
304        }
305        if let Some(rest) = body.strip_prefix("Exec:") {
306            let exec_off = offset + leading + "Exec:".len();
307            let trim_off = rest.len() - rest.trim_start().len();
308            let value_after_colon = rest.trim_start();
309            // Multi-line form: `Exec: <<<` ... lines ... `>>>` on its own line.
310            if let Some(after_open) = value_after_colon.strip_prefix("<<<") {
311                let trailing = after_open.trim();
312                let opener_span_start = exec_off + trim_off;
313                if !trailing.is_empty() {
314                    self.err(
315                        "unexpected text after `<<<` on Exec line",
316                        opener_span_start..(opener_span_start + value_after_colon.len()),
317                        "the multi-line Exec opener must be alone on its line",
318                    );
319                }
320                let mut lines: Vec<(String, Span)> = Vec::new();
321                let mut closed = false;
322                let mut end_off = offset + text.len();
323                while let Some((line_text, line_off)) = self.peek() {
324                    self.cursor += 1;
325                    end_off = line_off + line_text.len();
326                    let trimmed_line = line_text.trim();
327                    if trimmed_line == ">>>" {
328                        closed = true;
329                        break;
330                    }
331                    if trimmed_line.is_empty() || trimmed_line.starts_with('#') {
332                        continue;
333                    }
334                    let lead = line_text.len() - line_text.trim_start().len();
335                    let span_start = line_off + lead;
336                    let span = span_start..(span_start + trimmed_line.len());
337                    lines.push((trimmed_line.to_string(), span));
338                }
339                if !closed {
340                    self.err(
341                        "unterminated multi-line Exec block",
342                        opener_span_start..(opener_span_start + 3),
343                        "missing closing `>>>` on its own line",
344                    );
345                }
346                let mut statements = Vec::new();
347                let mut raw_parts = Vec::new();
348                for (line, span) in lines {
349                    raw_parts.push(line.clone());
350                    match crate::parse::exec::parse_exec(&line, span.start) {
351                        Ok(stages) => statements.push(stages),
352                        Err(d) => self.diags.push(d),
353                    }
354                }
355                ep.exec = ExecSpec {
356                    raw: raw_parts.join("\n"),
357                    span: opener_span_start..end_off,
358                    statements,
359                };
360                ep.span.end = end_off;
361                return true;
362            }
363            let raw = rest.trim().to_string();
364            let span = (exec_off + trim_off)..(exec_off + trim_off + raw.len());
365            let statements = match crate::parse::exec::parse_exec(&raw, span.start) {
366                Ok(p) => vec![p],
367                Err(d) => {
368                    self.diags.push(d);
369                    Vec::new()
370                }
371            };
372            ep.exec = ExecSpec {
373                raw,
374                span,
375                statements,
376            };
377            ep.span.end = offset + text.len();
378            return true;
379        }
380
381        let (key, rest) = split_first_word(body);
382        let key_off = offset + leading;
383        let rest_off = key_off + key.len();
384        let rest_trim_off = rest.len() - rest.trim_start().len();
385        let value = rest.trim();
386        let val_off = rest_off + rest_trim_off;
387
388        match key {
389            "QUERY" => match self.parse_named_field(value, val_off) {
390                Ok(f) => ep.query_params.push(f),
391                Err(d) => self.diags.push(d),
392            },
393            "HEADER" => match self.parse_named_field(value, val_off) {
394                Ok(f) => ep.headers.push(f),
395                Err(d) => self.diags.push(d),
396            },
397            "VAR" => match self.parse_var_def(value, val_off) {
398                Ok(v) => ep.vars.push(v),
399                Err(d) => self.diags.push(d),
400            },
401            "BODY" => self.parse_body(ep, value, val_off),
402            other => self.err(
403                format!("unknown directive `{}`", other),
404                key_off..key_off + key.len(),
405                "expected QUERY, HEADER, VAR, BODY, Response-Type or Exec",
406            ),
407        }
408        false
409    }
410
411    fn parse_path(&mut self, path: &str, offset: usize) -> Vec<PathSegment> {
412        let mut segs = Vec::new();
413        if !path.starts_with('/') {
414            self.err(
415                "path must start with `/`",
416                offset..(offset + path.len()),
417                "add a leading slash",
418            );
419        }
420        for (idx, raw) in path.split('/').enumerate() {
421            if idx == 0 {
422                continue;
423            }
424            // compute span of this segment
425            // (rough; sufficient for diagnostics)
426            let local_off = offset
427                + path
428                    .match_indices('/')
429                    .nth(idx - 1)
430                    .map(|(i, _)| i + 1)
431                    .unwrap_or(0);
432            let seg_span = local_off..(local_off + raw.len());
433            if raw.is_empty() {
434                continue;
435            }
436            if let Some(rest) = raw.strip_prefix(':') {
437                let mut parts = rest.splitn(2, ':');
438                let name = parts.next().unwrap_or("").to_string();
439                let ty_str = parts.next().unwrap_or("string");
440                if name.is_empty() {
441                    self.err(
442                        "empty path parameter name",
443                        seg_span.clone(),
444                        "use `:name:type`",
445                    );
446                    continue;
447                }
448                let ty = match parse_type_expr(ty_str, seg_span.end - ty_str.len()) {
449                    Ok(t) => t,
450                    Err(d) => {
451                        self.diags.push(d);
452                        TypeExpr::String
453                    }
454                };
455                segs.push(PathSegment::Param {
456                    name,
457                    ty,
458                    span: seg_span,
459                });
460            } else {
461                segs.push(PathSegment::Literal(raw.to_string()));
462            }
463        }
464        segs
465    }
466
467    fn parse_named_field(&mut self, value: &str, offset: usize) -> Result<NamedField, Diag> {
468        // syntax: name[?]: <type>
469        let head = split_field_head(value, offset)?;
470        let ty = parse_type_expr(head.tail, head.tail_off)?;
471        Ok(NamedField {
472            name: head.name,
473            optional: head.optional,
474            ty,
475            span: offset..(offset + value.len()),
476        })
477    }
478
479    fn parse_var_def(&mut self, value: &str, offset: usize) -> Result<VarDef, Diag> {
480        // syntax: VAR name <source>
481        let (name, rest) = split_first_word(value);
482        if name.is_empty() {
483            return Err(Diag::error(
484                "missing var name",
485                offset..offset + value.len(),
486                "expected `VAR name <source>`",
487            ));
488        }
489        let rest_trim_off = rest.len() - rest.trim_start().len();
490        let src_str = rest.trim();
491        let src_off = offset + name.len() + rest_trim_off;
492        let source = parse_value_source(src_str, src_off)?;
493        Ok(VarDef {
494            name: name.to_string(),
495            source,
496            span: offset..(offset + value.len()),
497        })
498    }
499
500    fn parse_body(&mut self, ep: &mut Endpoint, value: &str, offset: usize) {
501        // Cases:
502        //   BODY string
503        //   BODY json
504        //   BODY binary
505        //   BODY json { ... }
506        //   BODY form { ... }
507        let (kind, rest) = split_first_word(value);
508        let kind_span = offset..(offset + kind.len());
509        let rest_trim = rest.trim();
510        let opens_block = rest_trim.starts_with('{');
511        match kind {
512            "string" => {
513                if opens_block {
514                    self.err("BODY string takes no schema", kind_span.clone(), "");
515                }
516                ep.body = Some(BodySpec::String { span: kind_span });
517            }
518            "binary" => {
519                if opens_block {
520                    self.err("BODY binary takes no schema", kind_span.clone(), "");
521                }
522                ep.body = Some(BodySpec::Binary { span: kind_span });
523            }
524            "json" => {
525                if !opens_block {
526                    ep.body = Some(BodySpec::Json {
527                        schema: None,
528                        span: kind_span,
529                    });
530                } else {
531                    let fields = self.parse_json_block();
532                    ep.body = Some(BodySpec::Json {
533                        schema: Some(JsonSchema { fields }),
534                        span: kind_span,
535                    });
536                }
537            }
538            "form" => {
539                if !opens_block {
540                    self.err(
541                        "BODY form requires `{ ... }` schema",
542                        kind_span.clone(),
543                        "add a `{` block listing form fields",
544                    );
545                    ep.body = Some(BodySpec::Form {
546                        fields: Vec::new(),
547                        span: kind_span,
548                    });
549                } else {
550                    let fields = self.parse_form_block();
551                    ep.body = Some(BodySpec::Form {
552                        fields,
553                        span: kind_span,
554                    });
555                }
556            }
557            other => self.err(
558                format!("unknown body kind `{}`", other),
559                kind_span,
560                "expected one of: string, json, form, binary",
561            ),
562        }
563    }
564
565    fn parse_form_block(&mut self) -> Vec<NamedField> {
566        self.parse_brace_block("BODY form", |this, val, off| {
567            this.parse_named_field(val, off)
568        })
569    }
570
571    fn parse_json_block(&mut self) -> Vec<JsonField> {
572        self.parse_brace_block("BODY json", |this, val, off| {
573            this.parse_json_field(val, off)
574        })
575    }
576
577    /// Generic `{ ... }` block parser used by `BODY form` and `BODY json`.
578    /// Lines are stripped of trailing commas and dispatched to `line_parser`.
579    /// Errors from the line parser are recorded as diagnostics; the block ends
580    /// at the line containing only `}`.
581    fn parse_brace_block<T, F>(&mut self, label: &str, mut line_parser: F) -> Vec<T>
582    where
583        F: FnMut(&mut Self, &str, usize) -> Result<T, Diag>,
584    {
585        let mut out = Vec::new();
586        loop {
587            self.skip_blank_and_comments();
588            let Some((text, off)) = self.peek() else {
589                self.err(format!("unterminated {} block", label), 0..0, "missing `}`");
590                break;
591            };
592            let t = text.trim();
593            if t == "}" {
594                self.cursor += 1;
595                break;
596            }
597            self.cursor += 1;
598            let leading = text.len() - text.trim_start().len();
599            let val = t.trim_end_matches(',').trim();
600            match line_parser(self, val, off + leading) {
601                Ok(f) => out.push(f),
602                Err(d) => self.diags.push(d),
603            }
604        }
605        out
606    }
607
608    fn parse_json_field(&mut self, value: &str, offset: usize) -> Result<JsonField, Diag> {
609        let head = split_field_head(value, offset)?;
610        let ty = if let Some(inner) = head
611            .tail
612            .strip_prefix('[')
613            .and_then(|s| s.strip_suffix(']'))
614        {
615            JsonFieldType::Array(parse_type_expr(inner.trim(), head.tail_off + 1)?)
616        } else {
617            JsonFieldType::Scalar(parse_type_expr(head.tail, head.tail_off)?)
618        };
619        Ok(JsonField {
620            name: head.name,
621            optional: head.optional,
622            ty,
623            span: offset..(offset + value.len()),
624        })
625    }
626}
627
628/// `name[?]: <rest>` decomposition shared by `parse_named_field` and
629/// `parse_json_field`. Returns the trimmed name, optionality, the substring
630/// after the colon, and that substring's absolute byte offset.
631struct FieldHead<'a> {
632    name: String,
633    optional: bool,
634    tail: &'a str,
635    tail_off: usize,
636}
637
638fn split_field_head(value: &str, offset: usize) -> Result<FieldHead<'_>, Diag> {
639    let colon_pos = value.find(':').ok_or_else(|| {
640        Diag::error(
641            "missing `:` in field declaration",
642            offset..offset + value.len(),
643            "expected `name: <type>`",
644        )
645    })?;
646    let head = &value[..colon_pos];
647    let after = &value[colon_pos + 1..];
648    let tail = after.trim_start();
649    let tail_off = offset + colon_pos + 1 + (after.len() - tail.len());
650    let (name, optional) = if let Some(stripped) = head.strip_suffix('?') {
651        (stripped.trim().to_string(), true)
652    } else {
653        (head.trim().to_string(), false)
654    };
655    if name.is_empty() {
656        return Err(Diag::error(
657            "empty field name",
658            offset..offset + value.len(),
659            "expected a name before `:`",
660        ));
661    }
662    Ok(FieldHead {
663        name,
664        optional,
665        tail,
666        tail_off,
667    })
668}
669
670fn split_first_word(s: &str) -> (&str, &str) {
671    let s = s.trim_start();
672    let end = s.find(|c: char| c.is_whitespace()).unwrap_or(s.len());
673    (&s[..end], &s[end..])
674}
675
676fn parse_value_source(s: &str, offset: usize) -> Result<ValueSource, Diag> {
677    let s = s.trim();
678    if let Some(inner) = s.strip_prefix('[').and_then(|x| x.strip_suffix(']')) {
679        let inner = inner.trim();
680        let (kind, rest) = split_first_word(inner);
681        let rest = rest.trim();
682        match kind {
683            "ENV" => Ok(ValueSource::Env {
684                name: rest.to_string(),
685                span: offset..offset + s.len(),
686            }),
687            "HEADER" => Ok(ValueSource::Header {
688                name: rest.to_string(),
689                span: offset..offset + s.len(),
690            }),
691            other => Err(Diag::error(
692                format!("unknown value source `{}`", other),
693                offset..offset + s.len(),
694                "expected [ENV NAME] or [HEADER NAME]",
695            )),
696        }
697    } else if !s.is_empty() {
698        Ok(ValueSource::Literal {
699            value: s.to_string(),
700            span: offset..offset + s.len(),
701        })
702    } else {
703        Err(Diag::error(
704            "missing value source",
705            offset..offset,
706            "expected [ENV NAME], [HEADER NAME] or a literal",
707        ))
708    }
709}
710
711fn parse_auth(value: &str, offset: usize) -> Result<AuthSpec, Diag> {
712    let value = value.trim();
713    let (scheme, rest) = split_first_word(value);
714    let rest = rest.trim();
715    if !scheme.eq_ignore_ascii_case("Bearer") {
716        return Err(Diag::error(
717            format!("unsupported auth scheme `{}`", scheme),
718            offset..offset + scheme.len(),
719            "only Bearer is supported",
720        ));
721    }
722    let inner = rest
723        .strip_prefix('[')
724        .and_then(|s| s.strip_suffix(']'))
725        .ok_or_else(|| {
726            Diag::error(
727                "missing `[HEADER name]` after Bearer",
728                offset..offset + value.len(),
729                "expected `AUTH Bearer [HEADER NAME]`",
730            )
731        })?;
732    let (kind, name) = split_first_word(inner.trim());
733    let name = name.trim();
734    if !kind.eq_ignore_ascii_case("HEADER") {
735        return Err(Diag::error(
736            format!("unsupported auth source `{}`", kind),
737            offset..offset + value.len(),
738            "only [HEADER NAME] is supported",
739        ));
740    }
741    if name.is_empty() {
742        return Err(Diag::error(
743            "missing header name",
744            offset..offset + value.len(),
745            "expected `[HEADER NAME]`",
746        ));
747    }
748    Ok(AuthSpec::BearerHeader {
749        header: name.to_string(),
750        span: offset..offset + value.len(),
751    })
752}
753
754fn parse_size(s: &str) -> Option<u64> {
755    parse_suffixed(
756        s,
757        &[
758            ("kb", 1024),
759            ("mb", 1024 * 1024),
760            ("gb", 1024 * 1024 * 1024),
761            ("b", 1),
762        ],
763        1,
764    )
765}
766
767fn parse_duration_ms(s: &str) -> Option<u64> {
768    parse_suffixed(s, &[("ms", 1), ("s", 1000), ("m", 60_000)], 1000)
769}
770
771/// Strip a known suffix and multiply the leading integer by its weight.
772/// `default_mult` applies when no suffix matches. Suffixes are tried in order,
773/// so list multi-char suffixes (e.g. `"ms"`) before their prefixes (`"s"`).
774fn parse_suffixed(s: &str, suffixes: &[(&str, u64)], default_mult: u64) -> Option<u64> {
775    let s = s.trim().to_ascii_lowercase();
776    let (num, mult) = suffixes
777        .iter()
778        .find_map(|(suf, m)| s.strip_suffix(suf).map(|rest| (rest.trim(), *m)))
779        .unwrap_or((s.as_str(), default_mult));
780    num.trim().parse::<u64>().ok()?.checked_mul(mult)
781}
782
783pub fn parse_type_expr(s: &str, offset: usize) -> Result<TypeExpr, Diag> {
784    let s = s.trim();
785    if s.is_empty() {
786        return Err(Diag::error(
787            "missing type",
788            offset..offset,
789            "expected a type expression",
790        ));
791    }
792    // regex
793    if let Some(stripped) = s.strip_prefix('/') {
794        if let Some(pat) = stripped.strip_suffix('/') {
795            return Ok(TypeExpr::Regex {
796                pattern: pat.to_string(),
797                span: offset..offset + s.len(),
798            });
799        } else {
800            return Err(Diag::error(
801                "unterminated regex",
802                offset..offset + s.len(),
803                "regex must be enclosed in `/.../`",
804            ));
805        }
806    }
807    // int range / float range
808    if let Some(rest) = s.strip_prefix("int(")
809        && let Some(inner) = rest.strip_suffix(')')
810    {
811        let parts: Vec<&str> = inner.splitn(2, "..").collect();
812        if parts.len() == 2
813            && let (Ok(a), Ok(b)) = (
814                parts[0].trim().parse::<i64>(),
815                parts[1].trim().parse::<i64>(),
816            )
817        {
818            return Ok(TypeExpr::IntRange {
819                min: a,
820                max: b,
821                span: offset..offset + s.len(),
822            });
823        }
824        return Err(Diag::error(
825            "invalid int range",
826            offset..offset + s.len(),
827            "expected `int(a..b)`",
828        ));
829    }
830    if let Some(rest) = s.strip_prefix("float(")
831        && let Some(inner) = rest.strip_suffix(')')
832    {
833        let parts: Vec<&str> = inner.splitn(2, "..").collect();
834        if parts.len() == 2
835            && let (Ok(a), Ok(b)) = (
836                parts[0].trim().parse::<f64>(),
837                parts[1].trim().parse::<f64>(),
838            )
839        {
840            return Ok(TypeExpr::FloatRange {
841                min: a,
842                max: b,
843                span: offset..offset + s.len(),
844            });
845        }
846        return Err(Diag::error(
847            "invalid float range",
848            offset..offset + s.len(),
849            "expected `float(a..b)`",
850        ));
851    }
852    match s {
853        "int" => Ok(TypeExpr::Int),
854        "float" => Ok(TypeExpr::Float),
855        "boolean" | "bool" => Ok(TypeExpr::Boolean),
856        "uuid" => Ok(TypeExpr::Uuid),
857        "string" => Ok(TypeExpr::String),
858        "json" => Ok(TypeExpr::Json),
859        "binary" => Ok(TypeExpr::Binary),
860        _ if s.contains('|') => {
861            let variants: Vec<String> = s
862                .split('|')
863                .map(|v| v.trim().to_string())
864                .filter(|v| !v.is_empty())
865                .collect();
866            if variants.is_empty() {
867                Err(Diag::error(
868                    "empty union",
869                    offset..offset + s.len(),
870                    "expected at least one variant",
871                ))
872            } else {
873                Ok(TypeExpr::Union {
874                    variants,
875                    span: offset..offset + s.len(),
876                })
877            }
878        }
879        other => Err(Diag::error(
880            format!("unknown type `{}`", other),
881            offset..offset + s.len(),
882            "expected int, float, boolean, uuid, string, json, binary, a range, union or regex",
883        )),
884    }
885}