1use std::sync::Arc;
2
3use rustc_hash::FxHashSet;
4
5use crate::config::graph_filtering::GRAPH_FILTERING;
6use crate::types::{Fragment, FragmentId, FragmentKind};
7
8fn is_signature_eligible(kind: FragmentKind) -> bool {
9 matches!(
13 kind,
14 FragmentKind::Function
15 | FragmentKind::Class
16 | FragmentKind::Struct
17 | FragmentKind::Interface
18 | FragmentKind::Enum
19 | FragmentKind::Variable
20 )
21}
22
23fn signature_kind(kind: FragmentKind) -> FragmentKind {
24 match kind {
25 FragmentKind::Function => FragmentKind::FunctionSignature,
26 FragmentKind::Class => FragmentKind::ClassSignature,
27 FragmentKind::Struct => FragmentKind::StructSignature,
28 FragmentKind::Interface => FragmentKind::InterfaceSignature,
29 FragmentKind::Enum => FragmentKind::EnumSignature,
30 _ => FragmentKind::FunctionSignature,
31 }
32}
33
34fn count_brackets_outside_strings(line: &str) -> (i32, i32, i32, i32) {
35 let mut open_parens = 0i32;
36 let mut close_parens = 0i32;
37 let mut open_braces = 0i32;
38 let mut close_braces = 0i32;
39 let mut in_string: Option<char> = None;
40 let mut escaped = false;
41
42 for ch in line.chars() {
43 if let Some(quote) = in_string {
44 if escaped {
45 escaped = false;
46 } else if ch == '\\' {
47 escaped = true;
48 } else if ch == quote {
49 in_string = None;
50 }
51 continue;
52 }
53 match ch {
54 '\'' | '"' | '`' => {
55 in_string = Some(ch);
56 escaped = false;
57 }
58 '(' => open_parens += 1,
59 ')' => close_parens += 1,
60 '{' => open_braces += 1,
61 '}' => close_braces += 1,
62 _ => {}
63 }
64 }
65
66 (open_parens, close_parens, open_braces, close_braces)
67}
68
69fn decorator_prefix_len(lines: &[&str]) -> usize {
70 let mut i = 0;
71 let mut paren_depth = 0i32;
72 while i < lines.len() {
73 let trimmed = lines[i].trim_start();
74 let starts_decorator = trimmed.starts_with('@') || trimmed.starts_with("#[");
75 if paren_depth <= 0 && !starts_decorator {
76 break;
77 }
78 let (op, cp, _, _) = count_brackets_outside_strings(lines[i]);
79 paren_depth += op - cp;
80 i += 1;
81 }
82 if i >= lines.len() { 0 } else { i }
83}
84
85fn find_signature_end(lines: &[&str]) -> usize {
86 let mut paren_depth = 0i32;
87 let mut seen_open_paren = false;
88
89 for (i, line) in lines.iter().enumerate() {
90 let (op, cp, ob, cb) = count_brackets_outside_strings(line);
91 paren_depth += op - cp;
92 if op > 0 {
93 seen_open_paren = true;
94 }
95 if paren_depth <= 0 && ob - cb > 0 {
100 return i + 1;
101 }
102 if seen_open_paren && paren_depth <= 0 {
103 return i + 1;
104 }
105 }
106
107 2.min(lines.len())
108}
109
110pub fn generate_signature_variants(fragments: &[Fragment]) -> Vec<Fragment> {
111 let mut signatures: Vec<Fragment> = Vec::new();
112 let mut seen: FxHashSet<FragmentId> = FxHashSet::default();
113
114 for frag in fragments {
115 if !is_signature_eligible(frag.kind) {
116 continue;
117 }
118 if frag.line_count() < GRAPH_FILTERING.min_lines_for_signature {
119 continue;
120 }
121 let lines: Vec<&str> = frag.content.lines().collect();
122 if lines.is_empty() {
128 continue;
129 }
130 let decorators = decorator_prefix_len(&lines);
131 let sig_end = (decorators + find_signature_end(&lines[decorators..])).max(1);
132 let sig_content: String = lines[..sig_end.min(lines.len())].join("\n");
133 let sig_end_line = frag.start_line() + sig_end as u32 - 1;
134 let sig_id = FragmentId::new(frag.id.path.clone(), frag.start_line(), sig_end_line);
135
136 if seen.contains(&sig_id) {
137 continue;
138 }
139 seen.insert(sig_id.clone());
140
141 signatures.push(Fragment {
142 id: sig_id,
143 kind: signature_kind(frag.kind),
144 content: Arc::from(sig_content),
145 identifiers: frag.identifiers.clone(),
146 token_count: 0,
147 symbol_name: frag.symbol_name.clone(),
148 });
149 }
150
151 signatures
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 fn frag(kind: FragmentKind, start: u32, content: &str) -> Fragment {
159 let line_count = content.lines().count() as u32;
160 Fragment {
161 id: FragmentId::new(Arc::from("a.src"), start, start + line_count - 1),
162 kind,
163 content: Arc::from(content),
164 identifiers: FxHashSet::default(),
165 token_count: 100,
166 symbol_name: Some("target".into()),
167 }
168 }
169
170 fn stub_of(f: &Fragment) -> String {
171 let sigs = generate_signature_variants(std::slice::from_ref(f));
172 assert_eq!(sigs.len(), 1, "expected exactly one signature variant");
173 sigs[0].content.to_string()
174 }
175
176 #[test]
177 fn python_default_containing_braces_does_not_truncate_the_parameter_list() {
178 let f = frag(
181 FragmentKind::Function,
182 1,
183 "def f(\n x={},\n y=1,\n):\n body_one()\n body_two()\n",
184 );
185 let stub = stub_of(&f);
186 assert!(stub.contains("x={}"), "stub lost a parameter: {stub:?}");
187 assert!(stub.contains("y=1"), "stub lost a parameter: {stub:?}");
188 assert!(
189 stub.trim_end().ends_with("):"),
190 "stub is not closed: {stub:?}"
191 );
192 assert!(!stub.contains("body_one"), "stub leaked the body: {stub:?}");
193 }
194
195 #[test]
196 fn open_paren_inside_a_string_literal_does_not_extend_the_signature() {
197 let f = frag(
198 FragmentKind::Function,
199 1,
200 "def f(sep=\"a(b\"):\n one()\n two()\n three()\n four()\n",
201 );
202 let stub = stub_of(&f);
203 assert!(
204 stub.contains("sep=\"a(b\""),
205 "stub mangled the literal: {stub:?}"
206 );
207 assert!(!stub.contains("one()"), "stub leaked the body: {stub:?}");
208 }
209
210 #[test]
211 fn rust_attribute_prefix_is_kept_above_the_signature() {
212 let f = frag(
213 FragmentKind::Struct,
214 10,
215 "#[derive(Debug, Clone)]\npub struct S {\n a: u32,\n b: u32,\n c: u32,\n}\n",
216 );
217 let stub = stub_of(&f);
218 assert!(
219 stub.starts_with("#[derive(Debug, Clone)]"),
220 "lost the attribute: {stub:?}"
221 );
222 assert!(stub.contains("pub struct S {"), "lost the header: {stub:?}");
223 assert!(!stub.contains("a: u32"), "stub leaked the body: {stub:?}");
224 }
225
226 #[test]
227 fn multiline_decorator_prefix_is_kept_whole() {
228 let f = frag(
229 FragmentKind::Function,
230 1,
231 "@retry(\n times=3,\n)\ndef f(x):\n one()\n two()\n",
232 );
233 let stub = stub_of(&f);
234 assert!(stub.starts_with("@retry("), "lost the decorator: {stub:?}");
235 assert!(
236 stub.contains("times=3"),
237 "truncated the decorator: {stub:?}"
238 );
239 assert!(stub.contains("def f(x):"), "lost the signature: {stub:?}");
240 assert!(!stub.contains("one()"), "stub leaked the body: {stub:?}");
241 }
242
243 #[test]
244 fn signature_span_matches_the_emitted_line_count() {
245 let f = frag(
249 FragmentKind::Function,
250 42,
251 "def f(\n x,\n):\n one()\n two()\n three()\n",
252 );
253 let sigs = generate_signature_variants(std::slice::from_ref(&f));
254 let sig = &sigs[0];
255 assert_eq!(sig.id.start_line, 42);
256 assert_eq!(
257 sig.id.end_line - sig.id.start_line + 1,
258 sig.content.lines().count() as u32
259 );
260 }
261
262 #[test]
263 fn kind_maps_to_its_signature_variant_and_ineligible_kinds_are_skipped() {
264 let body = "\nline\nline\nline\nline\nline\n";
265 for (kind, expected) in [
266 (FragmentKind::Function, FragmentKind::FunctionSignature),
267 (FragmentKind::Class, FragmentKind::ClassSignature),
268 (FragmentKind::Struct, FragmentKind::StructSignature),
269 (FragmentKind::Interface, FragmentKind::InterfaceSignature),
270 (FragmentKind::Enum, FragmentKind::EnumSignature),
271 (FragmentKind::Variable, FragmentKind::FunctionSignature),
274 ] {
275 let sigs = generate_signature_variants(&[frag(kind, 1, body)]);
276 assert_eq!(sigs.len(), 1, "{kind:?} produced no signature");
277 assert_eq!(sigs[0].kind, expected, "{kind:?} mapped wrong");
278 }
279 assert!(generate_signature_variants(&[frag(FragmentKind::Chunk, 1, body)]).is_empty());
280 assert!(generate_signature_variants(&[frag(FragmentKind::Module, 1, body)]).is_empty());
281 }
282
283 #[test]
284 fn fragments_shorter_than_the_threshold_get_no_stub() {
285 let short = frag(FragmentKind::Function, 1, "def f():\n one()\n");
286 assert!(generate_signature_variants(&[short]).is_empty());
287 }
288
289 #[test]
290 fn duplicate_signature_spans_are_emitted_once() {
291 let a = frag(
292 FragmentKind::Function,
293 1,
294 "def f(x):\n a()\n b()\n c()\n d()\n",
295 );
296 let b = frag(
297 FragmentKind::Function,
298 1,
299 "def f(x):\n a()\n b()\n c()\n e()\n",
300 );
301 assert_eq!(generate_signature_variants(&[a, b]).len(), 1);
302 }
303
304 #[test]
305 fn signatureless_content_falls_back_to_a_bounded_prefix() {
306 let f = frag(
309 FragmentKind::Class,
310 1,
311 "class C:\n x = 1\n y = 2\n z = 3\n w = 4\n",
312 );
313 let stub = stub_of(&f);
314 assert!(
315 stub.lines().count() <= 2,
316 "fallback took too much: {stub:?}"
317 );
318 assert!(stub.starts_with("class C:"));
319 }
320
321 #[test]
326 fn a_span_wider_than_its_content_yields_no_malformed_signature() {
327 let empty_body = Fragment {
328 id: FragmentId::new(Arc::from("a.src"), 10, 40),
329 kind: FragmentKind::Function,
330 content: Arc::from(""),
331 identifiers: FxHashSet::default(),
332 token_count: 100,
333 symbol_name: Some("target".into()),
334 };
335
336 for sig in generate_signature_variants(&[empty_body]) {
337 assert!(
338 sig.end_line() >= sig.start_line(),
339 "signature id {:?} ends before it starts",
340 sig.id
341 );
342 assert!(sig.line_count() >= 1);
343 }
344 }
345}