Skip to main content

backhopper_core/model/
spec_parser.rs

1// Copyright (C) 2026 Michael S. Klishin and Contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3// See LICENSE-APACHE and LICENSE-MIT for details.
4
5//! Erlang type-expression parser. Recursive descent over `-spec` and
6//! `-callback` return-type text. Lossy by design: unrecognised
7//! constructs collapse to `SpecType::Unknown` so the comparator stays
8//! conservative.
9
10use std::str;
11
12use backhopper_erlang_scan::{
13    for_each_top_level_byte, is_name_byte, number_span, skip_char_literal_span,
14};
15
16use crate::model::spec_ast::SpecType;
17
18pub fn parse_signature_return(signature: &str) -> SpecType {
19    let Some(idx) = find_top_level_arrow(signature) else {
20        return SpecType::Unknown;
21    };
22    let rest = &signature[idx + 2..];
23    let body = strip_when_guard(rest);
24    parse(body)
25}
26
27pub fn parse(text: &str) -> SpecType {
28    let mut p = Parser::new(text);
29    let t = p.parse_union();
30    p.skip_ws();
31    t.canonicalise()
32}
33
34fn find_top_level_arrow(s: &str) -> Option<usize> {
35    let bytes = s.as_bytes();
36    for_each_top_level_byte(s, |i| bytes[i] == b'-' && bytes.get(i + 1) == Some(&b'>'))
37}
38
39fn strip_when_guard(rest: &str) -> &str {
40    let bytes = rest.as_bytes();
41    let found = for_each_top_level_byte(rest, |i| {
42        bytes[i..].starts_with(b"when") && is_word_boundary(bytes, i, i + 4)
43    });
44    match found {
45        Some(i) => rest[..i].trim_end(),
46        None => rest.trim(),
47    }
48}
49
50fn is_word_boundary(bytes: &[u8], start: usize, end: usize) -> bool {
51    let before_ok = start == 0 || !is_name_byte(bytes[start - 1]);
52    let after_ok = end >= bytes.len() || !is_name_byte(bytes[end]);
53    before_ok && after_ok
54}
55
56struct Parser<'a> {
57    bytes: &'a [u8],
58    cursor: usize,
59}
60
61const RECURSION_BUDGET: usize = 32;
62
63impl<'a> Parser<'a> {
64    fn new(text: &'a str) -> Self {
65        Self {
66            bytes: text.as_bytes(),
67            cursor: 0,
68        }
69    }
70
71    fn skip_ws(&mut self) {
72        while self.cursor < self.bytes.len() {
73            let b = self.bytes[self.cursor];
74            if b.is_ascii_whitespace() {
75                self.cursor += 1;
76            } else {
77                break;
78            }
79        }
80    }
81
82    fn peek(&self) -> Option<u8> {
83        self.bytes.get(self.cursor).copied()
84    }
85
86    fn peek_at(&self, offset: usize) -> Option<u8> {
87        self.bytes.get(self.cursor + offset).copied()
88    }
89
90    fn eat(&mut self, expected: u8) -> bool {
91        self.skip_ws();
92        if self.peek() == Some(expected) {
93            self.cursor += 1;
94            true
95        } else {
96            false
97        }
98    }
99
100    fn parse_union(&mut self) -> SpecType {
101        self.parse_union_with_budget(RECURSION_BUDGET)
102    }
103
104    fn parse_union_with_budget(&mut self, budget: usize) -> SpecType {
105        if budget == 0 {
106            return SpecType::Unknown;
107        }
108        let first = self.parse_single(budget - 1);
109        self.skip_ws();
110        if self.peek() != Some(b'|') {
111            return first;
112        }
113        let mut variants = vec![first];
114        while self.eat(b'|') {
115            let v = self.parse_single(budget - 1);
116            variants.push(v);
117        }
118        SpecType::Union { variants }
119    }
120
121    fn parse_single(&mut self, budget: usize) -> SpecType {
122        if budget == 0 {
123            return SpecType::Unknown;
124        }
125        self.skip_ws();
126        let Some(b) = self.peek() else {
127            return SpecType::Unknown;
128        };
129        match b {
130            b'{' => self.parse_tuple(budget),
131            b'[' => self.parse_list(budget),
132            b'#' => self.parse_hash(budget),
133            b'\'' => self.parse_quoted_atom(),
134            b'_' => self.parse_underscore(),
135            b'-' | b'0'..=b'9' | b'$' => self.parse_integer(),
136            b'<' if self.peek_at(1) == Some(b'<') => self.parse_binary(),
137            b if b.is_ascii_uppercase() => self.parse_var_or_unknown(budget),
138            b if b.is_ascii_lowercase() => self.parse_lowercase_form(budget),
139            _ => {
140                self.skip_unknown();
141                SpecType::Unknown
142            }
143        }
144    }
145
146    fn parse_tuple(&mut self, budget: usize) -> SpecType {
147        debug_assert_eq!(self.peek(), Some(b'{'));
148        self.cursor += 1;
149        self.skip_ws();
150        if self.peek() == Some(b'}') {
151            self.cursor += 1;
152            return SpecType::Tuple { arity: 0 };
153        }
154        let mut elements: Vec<SpecType> = Vec::new();
155        loop {
156            let elem = self.parse_union_with_budget(budget - 1);
157            elements.push(elem);
158            self.skip_ws();
159            match self.peek() {
160                Some(b',') => {
161                    self.cursor += 1;
162                }
163                Some(b'}') => {
164                    self.cursor += 1;
165                    break;
166                }
167                _ => {
168                    self.skip_until(b'}');
169                    return SpecType::Unknown;
170                }
171            }
172        }
173        let arity = elements.len().min(u8::MAX as usize) as u8;
174        match elements.first() {
175            Some(SpecType::Atom { name }) => SpecType::TaggedTuple {
176                tag: name.clone(),
177                arity,
178            },
179            _ => SpecType::Tuple { arity },
180        }
181    }
182
183    fn parse_list(&mut self, budget: usize) -> SpecType {
184        debug_assert_eq!(self.peek(), Some(b'['));
185        self.cursor += 1;
186        self.skip_ws();
187        if self.peek() == Some(b']') {
188            self.cursor += 1;
189            return SpecType::List {
190                element: Box::new(SpecType::Unknown),
191            };
192        }
193        let elem = self.parse_union_with_budget(budget - 1);
194        self.skip_until(b']');
195        SpecType::List {
196            element: Box::new(elem),
197        }
198    }
199
200    fn parse_hash(&mut self, budget: usize) -> SpecType {
201        debug_assert_eq!(self.peek(), Some(b'#'));
202        self.cursor += 1;
203        self.skip_ws();
204        match self.peek() {
205            Some(b'{') => {
206                self.cursor += 1;
207                self.skip_until_matching(b'{', b'}');
208                SpecType::Map
209            }
210            Some(b) if b.is_ascii_lowercase() || b == b'\'' => {
211                let name = self.read_atom_name();
212                self.skip_ws();
213                if self.peek() == Some(b'{') {
214                    self.cursor += 1;
215                    self.skip_until_matching(b'{', b'}');
216                }
217                SpecType::Record { name }
218            }
219            _ => {
220                let _ = budget;
221                self.skip_unknown();
222                SpecType::Unknown
223            }
224        }
225    }
226
227    fn parse_quoted_atom(&mut self) -> SpecType {
228        let name = self.read_quoted_atom();
229        if name.is_empty() {
230            return SpecType::Unknown;
231        }
232        SpecType::Atom { name }
233    }
234
235    fn parse_underscore(&mut self) -> SpecType {
236        let next = self.peek_at(1);
237        let is_word = match next {
238            Some(c) => is_name_byte(c),
239            None => false,
240        };
241        if !is_word {
242            self.cursor += 1;
243            return SpecType::Var {
244                name: "_".to_owned(),
245            };
246        }
247        let name = self.read_identifier();
248        SpecType::Var { name }
249    }
250
251    fn parse_integer(&mut self) -> SpecType {
252        let Some(low) = self.read_integer_value() else {
253            self.skip_unknown();
254            return SpecType::Unknown;
255        };
256        let save = self.cursor;
257        self.skip_ws();
258        if self.peek() == Some(b'.') && self.peek_at(1) == Some(b'.') {
259            self.cursor += 2;
260            self.skip_ws();
261            let Some(high) = self.read_integer_value() else {
262                self.skip_unknown();
263                return SpecType::Unknown;
264            };
265            return SpecType::Range { low, high };
266        }
267        self.cursor = save;
268        SpecType::Integer { value: low }
269    }
270
271    /// Reads one signed integer literal: decimal with `_` separators,
272    /// `base#digits`, or a `$c` char literal read as its code point.
273    /// `None` when the token is not a whole integer, such as a float.
274    fn read_integer_value(&mut self) -> Option<i64> {
275        let negative = self.peek() == Some(b'-');
276        if negative {
277            self.cursor += 1;
278        }
279        if self.peek() == Some(b'$') {
280            let span = skip_char_literal_span(self.bytes, self.cursor);
281            let raw = str::from_utf8(&self.bytes[self.cursor..self.cursor + span]).ok()?;
282            let value = char_literal_value(raw)?;
283            self.cursor += span;
284            return Some(if negative { -value } else { value });
285        }
286        let span = number_span(self.bytes, self.cursor)?;
287        let raw = str::from_utf8(&self.bytes[self.cursor..self.cursor + span]).ok()?;
288        let value = integer_literal_value(raw)?;
289        self.cursor += span;
290        Some(if negative { -value } else { value })
291    }
292
293    fn parse_binary(&mut self) -> SpecType {
294        debug_assert_eq!(self.peek(), Some(b'<'));
295        self.cursor += 2;
296        let mut depth = 1i32;
297        while self.cursor < self.bytes.len() {
298            if self.peek() == Some(b'<') && self.peek_at(1) == Some(b'<') {
299                depth += 1;
300                self.cursor += 2;
301            } else if self.peek() == Some(b'>') && self.peek_at(1) == Some(b'>') {
302                depth -= 1;
303                self.cursor += 2;
304                if depth == 0 {
305                    return SpecType::Binary;
306                }
307            } else {
308                self.cursor += 1;
309            }
310        }
311        SpecType::Binary
312    }
313
314    fn parse_var_or_unknown(&mut self, budget: usize) -> SpecType {
315        let name = self.read_identifier();
316        self.skip_ws();
317        match self.peek() {
318            // annotation: the name documents, the type after `::` is the shape
319            Some(b':') if self.peek_at(1) == Some(b':') => {
320                self.cursor += 2;
321                self.parse_union_with_budget(budget)
322            }
323            Some(b':') => {
324                self.cursor += 1;
325                let sub = self.read_identifier();
326                self.skip_ws();
327                if self.peek() == Some(b'(') {
328                    let arity = self.read_call_args(budget);
329                    SpecType::TypeCall {
330                        module: Some(name),
331                        name: sub,
332                        arity,
333                    }
334                } else {
335                    SpecType::Unknown
336                }
337            }
338            _ => SpecType::Var { name },
339        }
340    }
341
342    fn parse_lowercase_form(&mut self, budget: usize) -> SpecType {
343        if self.is_at_keyword(b"fun") {
344            self.cursor += 3;
345            self.skip_balanced(b'(', b')');
346            return SpecType::Fun;
347        }
348        let name = self.read_identifier();
349        self.skip_ws();
350        match self.peek() {
351            Some(b':') => {
352                self.cursor += 1;
353                let sub = self.read_identifier();
354                self.skip_ws();
355                if self.peek() == Some(b'(') {
356                    let arity = self.read_call_args(budget);
357                    SpecType::TypeCall {
358                        module: Some(name),
359                        name: sub,
360                        arity,
361                    }
362                } else {
363                    SpecType::Unknown
364                }
365            }
366            Some(b'(') => {
367                let arity = self.read_call_args(budget);
368                if is_builtin_type(&name) {
369                    SpecType::Builtin { name }
370                } else {
371                    SpecType::TypeCall {
372                        module: None,
373                        name,
374                        arity,
375                    }
376                }
377            }
378            _ => SpecType::Atom { name },
379        }
380    }
381
382    fn is_at_keyword(&self, word: &[u8]) -> bool {
383        if self.cursor + word.len() > self.bytes.len() {
384            return false;
385        }
386        if &self.bytes[self.cursor..self.cursor + word.len()] != word {
387            return false;
388        }
389        is_word_boundary(self.bytes, self.cursor, self.cursor + word.len())
390    }
391
392    fn read_identifier(&mut self) -> String {
393        let start = self.cursor;
394        while let Some(b) = self.peek() {
395            if is_name_byte(b) {
396                self.cursor += 1;
397            } else {
398                break;
399            }
400        }
401        str::from_utf8(&self.bytes[start..self.cursor])
402            .unwrap_or("")
403            .to_owned()
404    }
405
406    fn read_atom_name(&mut self) -> String {
407        if self.peek() == Some(b'\'') {
408            self.read_quoted_atom()
409        } else {
410            self.read_identifier()
411        }
412    }
413
414    fn read_quoted_atom(&mut self) -> String {
415        debug_assert_eq!(self.peek(), Some(b'\''));
416        self.cursor += 1;
417        let start = self.cursor;
418        let mut prev_escape = false;
419        while let Some(b) = self.peek() {
420            if prev_escape {
421                prev_escape = false;
422                self.cursor += 1;
423                continue;
424            }
425            if b == b'\\' {
426                prev_escape = true;
427                self.cursor += 1;
428                continue;
429            }
430            if b == b'\'' {
431                let raw = &self.bytes[start..self.cursor];
432                self.cursor += 1;
433                return str::from_utf8(raw).unwrap_or("").to_owned();
434            }
435            self.cursor += 1;
436        }
437        String::new()
438    }
439
440    fn read_call_args(&mut self, budget: usize) -> u8 {
441        debug_assert_eq!(self.peek(), Some(b'('));
442        self.cursor += 1;
443        self.skip_ws();
444        if self.peek() == Some(b')') {
445            self.cursor += 1;
446            return 0;
447        }
448        let mut count = 0u32;
449        loop {
450            let _ = self.parse_union_with_budget(budget.saturating_sub(1));
451            count += 1;
452            self.skip_ws();
453            match self.peek() {
454                Some(b',') => self.cursor += 1,
455                Some(b')') => {
456                    self.cursor += 1;
457                    break;
458                }
459                _ => {
460                    self.skip_until(b')');
461                    break;
462                }
463            }
464        }
465        count.min(u32::from(u8::MAX)) as u8
466    }
467
468    fn skip_unknown(&mut self) {
469        while let Some(b) = self.peek() {
470            if matches!(b, b',' | b'|' | b')' | b']' | b'}') {
471                break;
472            }
473            self.cursor += 1;
474        }
475    }
476
477    fn skip_until(&mut self, target: u8) {
478        let mut depth_paren = 0i32;
479        let mut depth_brace = 0i32;
480        let mut depth_brack = 0i32;
481        while let Some(b) = self.peek() {
482            match b {
483                b'(' => depth_paren += 1,
484                b')' => {
485                    if depth_paren == 0 && target == b')' {
486                        self.cursor += 1;
487                        return;
488                    }
489                    depth_paren -= 1;
490                }
491                b'{' => depth_brace += 1,
492                b'}' => {
493                    if depth_brace == 0 && target == b'}' {
494                        self.cursor += 1;
495                        return;
496                    }
497                    depth_brace -= 1;
498                }
499                b'[' => depth_brack += 1,
500                b']' => {
501                    if depth_brack == 0 && target == b']' {
502                        self.cursor += 1;
503                        return;
504                    }
505                    depth_brack -= 1;
506                }
507                _ => {}
508            }
509            self.cursor += 1;
510        }
511    }
512
513    fn skip_until_matching(&mut self, open: u8, close: u8) {
514        let mut depth = 1i32;
515        while let Some(b) = self.peek() {
516            if b == open {
517                depth += 1;
518            } else if b == close {
519                depth -= 1;
520                if depth == 0 {
521                    self.cursor += 1;
522                    return;
523                }
524            }
525            self.cursor += 1;
526        }
527    }
528
529    fn skip_balanced(&mut self, open: u8, close: u8) {
530        self.skip_ws();
531        if self.peek() != Some(open) {
532            return;
533        }
534        self.cursor += 1;
535        self.skip_until_matching(open, close);
536    }
537}
538
539/// Value of an integer literal slice: `_` separators and `base#digits`
540/// forms. `None` for floats and out-of-range values.
541fn integer_literal_value(raw: &str) -> Option<i64> {
542    let s = raw.replace('_', "");
543    if s.contains('.') {
544        return None;
545    }
546    match s.split_once('#') {
547        Some((base, digits)) => {
548            if digits.contains('#') {
549                return None;
550            }
551            let base: u32 = base.parse().ok()?;
552            if !(2..=36).contains(&base) {
553                return None;
554            }
555            i64::from_str_radix(digits, base).ok()
556        }
557        None => s.parse().ok(),
558    }
559}
560
561/// Code point of a `$c` char literal slice, escapes included. `None`
562/// for a malformed or truncated escape.
563fn char_literal_value(raw: &str) -> Option<i64> {
564    let body = raw.strip_prefix('$')?;
565    let mut chars = body.chars();
566    let first = chars.next()?;
567    if first != '\\' {
568        return Some(i64::from(u32::from(first)));
569    }
570    let esc = chars.next()?;
571    let value = match esc {
572        'n' => 10,
573        'r' => 13,
574        't' => 9,
575        'b' => 8,
576        'f' => 12,
577        'e' => 27,
578        's' => 32,
579        'd' => 127,
580        'v' => 11,
581        '^' => u32::from(chars.next()?) & 31,
582        'x' => {
583            let rest = chars.as_str();
584            let hex = rest
585                .strip_prefix('{')
586                .and_then(|r| r.strip_suffix('}'))
587                .unwrap_or(rest);
588            u32::from_str_radix(hex, 16).ok()?
589        }
590        '0'..='7' => {
591            let mut v = u32::from(esc) - u32::from('0');
592            for c in chars {
593                v = v * 8 + c.to_digit(8)?;
594            }
595            v
596        }
597        other => u32::from(other),
598    };
599    Some(i64::from(value))
600}
601
602fn is_builtin_type(name: &str) -> bool {
603    matches!(
604        name,
605        "any"
606            | "arity"
607            | "atom"
608            | "binary"
609            | "bitstring"
610            | "boolean"
611            | "byte"
612            | "char"
613            | "dynamic"
614            | "float"
615            | "function"
616            | "identifier"
617            | "integer"
618            | "iodata"
619            | "iolist"
620            | "list"
621            | "map"
622            | "maybe_improper_list"
623            | "mfa"
624            | "module"
625            | "neg_integer"
626            | "nil"
627            | "no_return"
628            | "node"
629            | "non_neg_integer"
630            | "none"
631            | "nonempty_binary"
632            | "nonempty_bitstring"
633            | "nonempty_improper_list"
634            | "nonempty_list"
635            | "nonempty_maybe_improper_list"
636            | "nonempty_string"
637            | "number"
638            | "pid"
639            | "port"
640            | "pos_integer"
641            | "reference"
642            | "string"
643            | "term"
644            | "timeout"
645            | "tuple"
646    )
647}