backhopper_core/snapshot/
spec_normalize.rs1use 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
20pub 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
31pub 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
54fn 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 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 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}