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 crate::model::spec_ast::SpecType;
13
14pub fn parse_signature_return(signature: &str) -> SpecType {
15    let Some(idx) = find_top_level_arrow(signature) else {
16        return SpecType::Unknown;
17    };
18    let rest = &signature[idx + 2..];
19    let body = strip_when_guard(rest);
20    parse(body)
21}
22
23pub fn parse(text: &str) -> SpecType {
24    let mut p = Parser::new(text);
25    let t = p.parse_union();
26    p.skip_ws();
27    t.canonicalise()
28}
29
30fn find_top_level_arrow(s: &str) -> Option<usize> {
31    let bytes = s.as_bytes();
32    let mut depth_paren = 0i32;
33    let mut depth_brace = 0i32;
34    let mut depth_brack = 0i32;
35    let mut in_string = false;
36    let mut in_atom_quote = false;
37    let mut i = 0usize;
38    while i + 1 < bytes.len() {
39        let ch = bytes[i] as char;
40        match ch {
41            '"' if !in_atom_quote => in_string = !in_string,
42            '\'' if !in_string => in_atom_quote = !in_atom_quote,
43            '(' if !in_string && !in_atom_quote => depth_paren += 1,
44            ')' if !in_string && !in_atom_quote => depth_paren -= 1,
45            '{' if !in_string && !in_atom_quote => depth_brace += 1,
46            '}' if !in_string && !in_atom_quote => depth_brace -= 1,
47            '[' if !in_string && !in_atom_quote => depth_brack += 1,
48            ']' if !in_string && !in_atom_quote => depth_brack -= 1,
49            '-' if !in_string
50                && !in_atom_quote
51                && depth_paren == 0
52                && depth_brace == 0
53                && depth_brack == 0
54                && bytes[i + 1] as char == '>' =>
55            {
56                return Some(i);
57            }
58            _ => {}
59        }
60        i += 1;
61    }
62    None
63}
64
65fn strip_when_guard(rest: &str) -> &str {
66    let bytes = rest.as_bytes();
67    let mut depth_paren = 0i32;
68    let mut depth_brace = 0i32;
69    let mut depth_brack = 0i32;
70    let mut in_string = false;
71    let mut in_atom_quote = false;
72    let mut i = 0usize;
73    while i + 4 <= bytes.len() {
74        let ch = bytes[i] as char;
75        match ch {
76            '"' if !in_atom_quote => in_string = !in_string,
77            '\'' if !in_string => in_atom_quote = !in_atom_quote,
78            '(' if !in_string && !in_atom_quote => depth_paren += 1,
79            ')' if !in_string && !in_atom_quote => depth_paren -= 1,
80            '{' if !in_string && !in_atom_quote => depth_brace += 1,
81            '}' if !in_string && !in_atom_quote => depth_brace -= 1,
82            '[' if !in_string && !in_atom_quote => depth_brack += 1,
83            ']' if !in_string && !in_atom_quote => depth_brack -= 1,
84            'w' if !in_string
85                && !in_atom_quote
86                && depth_paren == 0
87                && depth_brace == 0
88                && depth_brack == 0
89                && &bytes[i..i + 4] == b"when"
90                && is_word_boundary(bytes, i, i + 4) =>
91            {
92                return rest[..i].trim_end();
93            }
94            _ => {}
95        }
96        i += 1;
97    }
98    rest.trim()
99}
100
101fn is_word_boundary(bytes: &[u8], start: usize, end: usize) -> bool {
102    let before_ok = start == 0 || !is_ident_byte(bytes[start - 1]);
103    let after_ok = end >= bytes.len() || !is_ident_byte(bytes[end]);
104    before_ok && after_ok
105}
106
107fn is_ident_byte(b: u8) -> bool {
108    b.is_ascii_alphanumeric() || b == b'_' || b == b'@'
109}
110
111struct Parser<'a> {
112    bytes: &'a [u8],
113    cursor: usize,
114}
115
116const RECURSION_BUDGET: usize = 32;
117
118impl<'a> Parser<'a> {
119    fn new(text: &'a str) -> Self {
120        Self {
121            bytes: text.as_bytes(),
122            cursor: 0,
123        }
124    }
125
126    fn skip_ws(&mut self) {
127        while self.cursor < self.bytes.len() {
128            let b = self.bytes[self.cursor];
129            if b.is_ascii_whitespace() {
130                self.cursor += 1;
131            } else {
132                break;
133            }
134        }
135    }
136
137    fn peek(&self) -> Option<u8> {
138        self.bytes.get(self.cursor).copied()
139    }
140
141    fn peek_at(&self, offset: usize) -> Option<u8> {
142        self.bytes.get(self.cursor + offset).copied()
143    }
144
145    fn eat(&mut self, expected: u8) -> bool {
146        self.skip_ws();
147        if self.peek() == Some(expected) {
148            self.cursor += 1;
149            true
150        } else {
151            false
152        }
153    }
154
155    fn parse_union(&mut self) -> SpecType {
156        self.parse_union_with_budget(RECURSION_BUDGET)
157    }
158
159    fn parse_union_with_budget(&mut self, budget: usize) -> SpecType {
160        if budget == 0 {
161            return SpecType::Unknown;
162        }
163        let first = self.parse_single(budget - 1);
164        self.skip_ws();
165        if self.peek() != Some(b'|') {
166            return first;
167        }
168        let mut variants = vec![first];
169        while self.eat(b'|') {
170            let v = self.parse_single(budget - 1);
171            variants.push(v);
172        }
173        SpecType::Union { variants }
174    }
175
176    fn parse_single(&mut self, budget: usize) -> SpecType {
177        if budget == 0 {
178            return SpecType::Unknown;
179        }
180        self.skip_ws();
181        let Some(b) = self.peek() else {
182            return SpecType::Unknown;
183        };
184        match b {
185            b'{' => self.parse_tuple(budget),
186            b'[' => self.parse_list(budget),
187            b'#' => self.parse_hash(budget),
188            b'\'' => self.parse_quoted_atom(),
189            b'_' => self.parse_underscore(),
190            b'-' | b'0'..=b'9' => self.parse_integer(),
191            b if b.is_ascii_uppercase() => self.parse_var_or_unknown(budget),
192            b if b.is_ascii_lowercase() => self.parse_lowercase_form(budget),
193            _ => {
194                self.skip_unknown();
195                SpecType::Unknown
196            }
197        }
198    }
199
200    fn parse_tuple(&mut self, budget: usize) -> SpecType {
201        debug_assert_eq!(self.peek(), Some(b'{'));
202        self.cursor += 1;
203        self.skip_ws();
204        if self.peek() == Some(b'}') {
205            self.cursor += 1;
206            return SpecType::Tuple { arity: 0 };
207        }
208        let mut elements: Vec<SpecType> = Vec::new();
209        loop {
210            let elem = self.parse_union_with_budget(budget - 1);
211            elements.push(elem);
212            self.skip_ws();
213            match self.peek() {
214                Some(b',') => {
215                    self.cursor += 1;
216                }
217                Some(b'}') => {
218                    self.cursor += 1;
219                    break;
220                }
221                _ => {
222                    self.skip_until(b'}');
223                    return SpecType::Unknown;
224                }
225            }
226        }
227        let arity = elements.len().min(u8::MAX as usize) as u8;
228        match elements.first() {
229            Some(SpecType::Atom { name }) => SpecType::TaggedTuple {
230                tag: name.clone(),
231                arity,
232            },
233            _ => SpecType::Tuple { arity },
234        }
235    }
236
237    fn parse_list(&mut self, budget: usize) -> SpecType {
238        debug_assert_eq!(self.peek(), Some(b'['));
239        self.cursor += 1;
240        self.skip_ws();
241        if self.peek() == Some(b']') {
242            self.cursor += 1;
243            return SpecType::List {
244                element: Box::new(SpecType::Unknown),
245            };
246        }
247        let elem = self.parse_union_with_budget(budget - 1);
248        self.skip_until(b']');
249        SpecType::List {
250            element: Box::new(elem),
251        }
252    }
253
254    fn parse_hash(&mut self, budget: usize) -> SpecType {
255        debug_assert_eq!(self.peek(), Some(b'#'));
256        self.cursor += 1;
257        self.skip_ws();
258        match self.peek() {
259            Some(b'{') => {
260                self.cursor += 1;
261                self.skip_until_matching(b'{', b'}');
262                SpecType::Map
263            }
264            Some(b) if b.is_ascii_lowercase() || b == b'\'' => {
265                let name = self.read_atom_name();
266                self.skip_ws();
267                if self.peek() == Some(b'{') {
268                    self.cursor += 1;
269                    self.skip_until_matching(b'{', b'}');
270                }
271                SpecType::Record { name }
272            }
273            _ => {
274                let _ = budget;
275                self.skip_unknown();
276                SpecType::Unknown
277            }
278        }
279    }
280
281    fn parse_quoted_atom(&mut self) -> SpecType {
282        let name = self.read_quoted_atom();
283        if name.is_empty() {
284            return SpecType::Unknown;
285        }
286        SpecType::Atom { name }
287    }
288
289    fn parse_underscore(&mut self) -> SpecType {
290        let next = self.peek_at(1);
291        let is_word = match next {
292            Some(c) => is_ident_byte(c),
293            None => false,
294        };
295        if !is_word {
296            self.cursor += 1;
297            return SpecType::Var {
298                name: "_".to_owned(),
299            };
300        }
301        let name = self.read_identifier();
302        SpecType::Var { name }
303    }
304
305    fn parse_integer(&mut self) -> SpecType {
306        let start = self.cursor;
307        if self.peek() == Some(b'-') {
308            self.cursor += 1;
309        }
310        while let Some(b) = self.peek() {
311            if b.is_ascii_digit() {
312                self.cursor += 1;
313            } else {
314                break;
315            }
316        }
317        let raw = &self.bytes[start..self.cursor];
318        let s = str::from_utf8(raw).unwrap_or("");
319        match s.parse::<i64>() {
320            Ok(v) => SpecType::Integer { value: v },
321            Err(_) => SpecType::Unknown,
322        }
323    }
324
325    fn parse_var_or_unknown(&mut self, budget: usize) -> SpecType {
326        let name = self.read_identifier();
327        self.skip_ws();
328        match self.peek() {
329            Some(b':') => {
330                self.cursor += 1;
331                let sub = self.read_identifier();
332                self.skip_ws();
333                if self.peek() == Some(b'(') {
334                    let arity = self.read_call_args(budget);
335                    SpecType::TypeCall {
336                        module: Some(name),
337                        name: sub,
338                        arity,
339                    }
340                } else {
341                    SpecType::Unknown
342                }
343            }
344            _ => SpecType::Var { name },
345        }
346    }
347
348    fn parse_lowercase_form(&mut self, budget: usize) -> SpecType {
349        if self.is_at_keyword(b"fun") {
350            self.cursor += 3;
351            self.skip_balanced(b'(', b')');
352            return SpecType::Fun;
353        }
354        let name = self.read_identifier();
355        self.skip_ws();
356        match self.peek() {
357            Some(b':') => {
358                self.cursor += 1;
359                let sub = self.read_identifier();
360                self.skip_ws();
361                if self.peek() == Some(b'(') {
362                    let arity = self.read_call_args(budget);
363                    SpecType::TypeCall {
364                        module: Some(name),
365                        name: sub,
366                        arity,
367                    }
368                } else {
369                    SpecType::Unknown
370                }
371            }
372            Some(b'(') => {
373                let arity = self.read_call_args(budget);
374                if is_builtin_type(&name) {
375                    SpecType::Builtin { name }
376                } else {
377                    SpecType::TypeCall {
378                        module: None,
379                        name,
380                        arity,
381                    }
382                }
383            }
384            _ => SpecType::Atom { name },
385        }
386    }
387
388    fn is_at_keyword(&self, word: &[u8]) -> bool {
389        if self.cursor + word.len() > self.bytes.len() {
390            return false;
391        }
392        if &self.bytes[self.cursor..self.cursor + word.len()] != word {
393            return false;
394        }
395        is_word_boundary(self.bytes, self.cursor, self.cursor + word.len())
396    }
397
398    fn read_identifier(&mut self) -> String {
399        let start = self.cursor;
400        while let Some(b) = self.peek() {
401            if is_ident_byte(b) {
402                self.cursor += 1;
403            } else {
404                break;
405            }
406        }
407        str::from_utf8(&self.bytes[start..self.cursor])
408            .unwrap_or("")
409            .to_owned()
410    }
411
412    fn read_atom_name(&mut self) -> String {
413        if self.peek() == Some(b'\'') {
414            self.read_quoted_atom()
415        } else {
416            self.read_identifier()
417        }
418    }
419
420    fn read_quoted_atom(&mut self) -> String {
421        debug_assert_eq!(self.peek(), Some(b'\''));
422        self.cursor += 1;
423        let start = self.cursor;
424        let mut prev_escape = false;
425        while let Some(b) = self.peek() {
426            if prev_escape {
427                prev_escape = false;
428                self.cursor += 1;
429                continue;
430            }
431            if b == b'\\' {
432                prev_escape = true;
433                self.cursor += 1;
434                continue;
435            }
436            if b == b'\'' {
437                let raw = &self.bytes[start..self.cursor];
438                self.cursor += 1;
439                return str::from_utf8(raw).unwrap_or("").to_owned();
440            }
441            self.cursor += 1;
442        }
443        String::new()
444    }
445
446    fn read_call_args(&mut self, budget: usize) -> u8 {
447        debug_assert_eq!(self.peek(), Some(b'('));
448        self.cursor += 1;
449        self.skip_ws();
450        if self.peek() == Some(b')') {
451            self.cursor += 1;
452            return 0;
453        }
454        let mut count = 0u32;
455        loop {
456            let _ = self.parse_union_with_budget(budget.saturating_sub(1));
457            count += 1;
458            self.skip_ws();
459            match self.peek() {
460                Some(b',') => self.cursor += 1,
461                Some(b')') => {
462                    self.cursor += 1;
463                    break;
464                }
465                _ => {
466                    self.skip_until(b')');
467                    break;
468                }
469            }
470        }
471        count.min(u32::from(u8::MAX)) as u8
472    }
473
474    fn skip_unknown(&mut self) {
475        while let Some(b) = self.peek() {
476            if matches!(b, b',' | b'|' | b')' | b']' | b'}') {
477                break;
478            }
479            self.cursor += 1;
480        }
481    }
482
483    fn skip_until(&mut self, target: u8) {
484        let mut depth_paren = 0i32;
485        let mut depth_brace = 0i32;
486        let mut depth_brack = 0i32;
487        while let Some(b) = self.peek() {
488            match b {
489                b'(' => depth_paren += 1,
490                b')' => {
491                    if depth_paren == 0 && target == b')' {
492                        self.cursor += 1;
493                        return;
494                    }
495                    depth_paren -= 1;
496                }
497                b'{' => depth_brace += 1,
498                b'}' => {
499                    if depth_brace == 0 && target == b'}' {
500                        self.cursor += 1;
501                        return;
502                    }
503                    depth_brace -= 1;
504                }
505                b'[' => depth_brack += 1,
506                b']' => {
507                    if depth_brack == 0 && target == b']' {
508                        self.cursor += 1;
509                        return;
510                    }
511                    depth_brack -= 1;
512                }
513                _ => {}
514            }
515            self.cursor += 1;
516        }
517    }
518
519    fn skip_until_matching(&mut self, open: u8, close: u8) {
520        let mut depth = 1i32;
521        while let Some(b) = self.peek() {
522            if b == open {
523                depth += 1;
524            } else if b == close {
525                depth -= 1;
526                if depth == 0 {
527                    self.cursor += 1;
528                    return;
529                }
530            }
531            self.cursor += 1;
532        }
533    }
534
535    fn skip_balanced(&mut self, open: u8, close: u8) {
536        self.skip_ws();
537        if self.peek() != Some(open) {
538            return;
539        }
540        self.cursor += 1;
541        self.skip_until_matching(open, close);
542    }
543}
544
545fn is_builtin_type(name: &str) -> bool {
546    matches!(
547        name,
548        "any"
549            | "arity"
550            | "atom"
551            | "binary"
552            | "bitstring"
553            | "boolean"
554            | "byte"
555            | "char"
556            | "float"
557            | "function"
558            | "identifier"
559            | "integer"
560            | "iodata"
561            | "iolist"
562            | "list"
563            | "map"
564            | "maybe_improper_list"
565            | "mfa"
566            | "module"
567            | "neg_integer"
568            | "nil"
569            | "no_return"
570            | "node"
571            | "non_neg_integer"
572            | "none"
573            | "nonempty_list"
574            | "nonempty_string"
575            | "number"
576            | "pid"
577            | "port"
578            | "pos_integer"
579            | "reference"
580            | "string"
581            | "term"
582            | "timeout"
583            | "tuple"
584    )
585}