Skip to main content

backhopper_core/snapshot/
spec_normalize.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//! Canonical pretty-printer for `-spec`/`-callback`/`-type` right-hand sides.
6//!
7//! The job: produce a single canonical string for any given semantic
8//! signature, so reformatting in source produces no snapshot diff. The
9//! algorithm is intentionally simple: it normalizes whitespace, balances
10//! brackets, wraps at top-level `|` past a width target.
11
12use backhopper_erlang_scan::{
13    for_each_top_level_byte, quoted_atom_span, remove_line_comments, skip_char_literal_span,
14    string_span,
15};
16
17const WRAP_WIDTH: usize = 80;
18const CONTINUATION_INDENT: &str = "       ";
19
20/// Erlang entry point: `$c` char literals, triple-quoted strings, and
21/// sigils are lexed as such, so `$"` or `$,` cannot derail the walk.
22pub fn normalize_signature(input: &str) -> String {
23    let stripped = remove_line_comments(input);
24    let collapsed = collapse_whitespace_erlang(&stripped);
25    if collapsed.len() <= WRAP_WIDTH {
26        return collapsed;
27    }
28    wrap_at(&collapsed, &erlang_pipe_positions(&collapsed))
29}
30
31/// Elixir entry point: `$` is an ordinary byte in Elixir type text, so
32/// the char-literal-aware Erlang lexing must not apply.
33pub fn normalize_elixir_signature(input: &str) -> String {
34    let stripped = strip_line_comments(input);
35    let collapsed = collapse_whitespace(&stripped);
36    if collapsed.len() <= WRAP_WIDTH {
37        return collapsed;
38    }
39    wrap_at(&collapsed, &top_level_pipe_positions(&collapsed))
40}
41
42fn erlang_pipe_positions(input: &str) -> Vec<usize> {
43    let bytes = input.as_bytes();
44    let mut positions = Vec::new();
45    for_each_top_level_byte(input, |i| {
46        if bytes[i] == b'|' {
47            positions.push(i);
48        }
49        false
50    });
51    positions
52}
53
54/// Whitespace runs collapsed to one space, with string, quoted atom,
55/// and char-literal spans copied verbatim so their contents stay exact.
56fn collapse_whitespace_erlang(input: &str) -> String {
57    let bytes = input.as_bytes();
58    let mut out = String::with_capacity(input.len());
59    let mut prev_space = false;
60    let mut i = 0usize;
61    while i < bytes.len() {
62        let span = match bytes[i] {
63            b'"' | b'~' => string_span(bytes, i),
64            b'\'' => quoted_atom_span(bytes, i),
65            b'$' => Some(skip_char_literal_span(bytes, i)),
66            _ => None,
67        };
68        if let Some(span) = span {
69            // a span may end inside a multi-byte char (`$รค` reads two bytes)
70            let mut end = (i + span).min(bytes.len());
71            while !input.is_char_boundary(end) {
72                end += 1;
73            }
74            out.push_str(&input[i..end]);
75            prev_space = false;
76            i = end;
77            continue;
78        }
79        let ch = input[i..].chars().next().expect("char at boundary");
80        if ch.is_whitespace() {
81            if !prev_space && !out.is_empty() {
82                out.push(' ');
83                prev_space = true;
84            }
85        } else {
86            out.push(ch);
87            prev_space = false;
88        }
89        i += ch.len_utf8();
90    }
91    // drop the trailing space only when collapse added it
92    if prev_space {
93        out.pop();
94    }
95    out
96}
97
98fn strip_line_comments(input: &str) -> String {
99    let mut out = String::with_capacity(input.len());
100    let mut in_string = false;
101    let mut in_atom_quote = false;
102    let mut prev_back = false;
103    let mut chars = input.chars().peekable();
104    while let Some(ch) = chars.next() {
105        if prev_back {
106            out.push(ch);
107            prev_back = false;
108            continue;
109        }
110        if ch == '\\' && (in_string || in_atom_quote) {
111            out.push(ch);
112            prev_back = true;
113            continue;
114        }
115        if ch == '"' && !in_atom_quote {
116            in_string = !in_string;
117            out.push(ch);
118            continue;
119        }
120        if ch == '\'' && !in_string {
121            in_atom_quote = !in_atom_quote;
122            out.push(ch);
123            continue;
124        }
125        if ch == '%' && !in_string && !in_atom_quote {
126            for c in chars.by_ref() {
127                if c == '\n' {
128                    out.push('\n');
129                    break;
130                }
131            }
132            continue;
133        }
134        out.push(ch);
135    }
136    out
137}
138
139fn collapse_whitespace(input: &str) -> String {
140    let mut out = String::with_capacity(input.len());
141    let mut prev_space = false;
142    let mut in_string = false;
143    let mut in_atom_quote = false;
144    let mut prev_back = false;
145    for ch in input.chars() {
146        if prev_back {
147            out.push(ch);
148            prev_back = false;
149            continue;
150        }
151        if ch == '\\' && (in_string || in_atom_quote) {
152            out.push(ch);
153            prev_back = true;
154            continue;
155        }
156        if ch == '"' && !in_atom_quote {
157            in_string = !in_string;
158            out.push(ch);
159            prev_space = false;
160            continue;
161        }
162        if ch == '\'' && !in_string {
163            in_atom_quote = !in_atom_quote;
164            out.push(ch);
165            prev_space = false;
166            continue;
167        }
168        if in_string || in_atom_quote {
169            out.push(ch);
170            prev_space = false;
171            continue;
172        }
173        if ch.is_whitespace() {
174            if !prev_space && !out.is_empty() {
175                out.push(' ');
176                prev_space = true;
177            }
178        } else {
179            out.push(ch);
180            prev_space = false;
181        }
182    }
183    let new_len = out.trim_end_matches(' ').len();
184    out.truncate(new_len);
185    out
186}
187
188fn wrap_at(input: &str, pipes: &[usize]) -> String {
189    if pipes.is_empty() {
190        return input.to_owned();
191    }
192    let mut out = String::with_capacity(input.len() + pipes.len() * 8);
193    let mut last = 0usize;
194    for &pos in pipes {
195        let segment = input[last..pos].trim_end();
196        if last == 0 {
197            out.push_str(segment);
198        } else {
199            out.push('\n');
200            out.push_str(CONTINUATION_INDENT);
201            out.push_str(segment.trim_start());
202        }
203        last = pos;
204    }
205    let tail = input[last..].trim_end();
206    out.push('\n');
207    out.push_str(CONTINUATION_INDENT);
208    out.push_str(tail.trim_start());
209    out
210}
211
212fn top_level_pipe_positions(input: &str) -> Vec<usize> {
213    let mut depth_paren = 0i32;
214    let mut depth_brace = 0i32;
215    let mut depth_brack = 0i32;
216    let mut in_str = false;
217    let mut in_atom_quote = false;
218    let mut prev_was_backslash = false;
219    let mut positions = Vec::new();
220    for (i, ch) in input.char_indices() {
221        if prev_was_backslash {
222            prev_was_backslash = false;
223            continue;
224        }
225        match ch {
226            '\\' if in_str || in_atom_quote => prev_was_backslash = true,
227            '"' if !in_atom_quote => in_str = !in_str,
228            '\'' if !in_str => in_atom_quote = !in_atom_quote,
229            '(' if !in_str && !in_atom_quote => depth_paren += 1,
230            ')' if !in_str && !in_atom_quote => depth_paren -= 1,
231            '{' if !in_str && !in_atom_quote => depth_brace += 1,
232            '}' if !in_str && !in_atom_quote => depth_brace -= 1,
233            '[' if !in_str && !in_atom_quote => depth_brack += 1,
234            ']' if !in_str && !in_atom_quote => depth_brack -= 1,
235            '|' if !in_str
236                && !in_atom_quote
237                && depth_paren == 0
238                && depth_brace == 0
239                && depth_brack == 0 =>
240            {
241                positions.push(i);
242            }
243            _ => {}
244        }
245    }
246    positions
247}