Skip to main content

_diffctx/
signatures.rs

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    // Variable covers TS/JS arrow-function bindings (`const f = (...) => {...}`);
10    // without a stub variant a large changed arrow function that misses the core
11    // budget vanishes from the output entirely (#106).
12    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        // A body-opening brace only ends the signature once we are outside the
96        // parameter list. Braces inside parameter defaults or annotations
97        // (e.g. Python `def f(x={}):`) appear while `paren_depth > 0` and must
98        // not truncate the signature mid-parameter-list.
99        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        let decorators = decorator_prefix_len(&lines);
123        let sig_end = decorators + find_signature_end(&lines[decorators..]);
124        let sig_content: String = lines[..sig_end].join("\n");
125        let sig_end_line = frag.start_line() + sig_end as u32 - 1;
126        let sig_id = FragmentId::new(frag.id.path.clone(), frag.start_line(), sig_end_line);
127
128        if seen.contains(&sig_id) {
129            continue;
130        }
131        seen.insert(sig_id.clone());
132
133        signatures.push(Fragment {
134            id: sig_id,
135            kind: signature_kind(frag.kind),
136            content: Arc::from(sig_content),
137            identifiers: frag.identifiers.clone(),
138            token_count: 0,
139            symbol_name: frag.symbol_name.clone(),
140        });
141    }
142
143    signatures
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn frag(kind: FragmentKind, start: u32, content: &str) -> Fragment {
151        let line_count = content.lines().count() as u32;
152        Fragment {
153            id: FragmentId::new(Arc::from("a.src"), start, start + line_count - 1),
154            kind,
155            content: Arc::from(content),
156            identifiers: FxHashSet::default(),
157            token_count: 100,
158            symbol_name: Some("target".into()),
159        }
160    }
161
162    fn stub_of(f: &Fragment) -> String {
163        let sigs = generate_signature_variants(std::slice::from_ref(f));
164        assert_eq!(sigs.len(), 1, "expected exactly one signature variant");
165        sigs[0].content.to_string()
166    }
167
168    #[test]
169    fn python_default_containing_braces_does_not_truncate_the_parameter_list() {
170        // `x={}` puts a brace inside the parameter list; truncating there would
171        // ship a syntactically broken stub.
172        let f = frag(
173            FragmentKind::Function,
174            1,
175            "def f(\n    x={},\n    y=1,\n):\n    body_one()\n    body_two()\n",
176        );
177        let stub = stub_of(&f);
178        assert!(stub.contains("x={}"), "stub lost a parameter: {stub:?}");
179        assert!(stub.contains("y=1"), "stub lost a parameter: {stub:?}");
180        assert!(
181            stub.trim_end().ends_with("):"),
182            "stub is not closed: {stub:?}"
183        );
184        assert!(!stub.contains("body_one"), "stub leaked the body: {stub:?}");
185    }
186
187    #[test]
188    fn open_paren_inside_a_string_literal_does_not_extend_the_signature() {
189        let f = frag(
190            FragmentKind::Function,
191            1,
192            "def f(sep=\"a(b\"):\n    one()\n    two()\n    three()\n    four()\n",
193        );
194        let stub = stub_of(&f);
195        assert!(
196            stub.contains("sep=\"a(b\""),
197            "stub mangled the literal: {stub:?}"
198        );
199        assert!(!stub.contains("one()"), "stub leaked the body: {stub:?}");
200    }
201
202    #[test]
203    fn rust_attribute_prefix_is_kept_above_the_signature() {
204        let f = frag(
205            FragmentKind::Struct,
206            10,
207            "#[derive(Debug, Clone)]\npub struct S {\n    a: u32,\n    b: u32,\n    c: u32,\n}\n",
208        );
209        let stub = stub_of(&f);
210        assert!(
211            stub.starts_with("#[derive(Debug, Clone)]"),
212            "lost the attribute: {stub:?}"
213        );
214        assert!(stub.contains("pub struct S {"), "lost the header: {stub:?}");
215        assert!(!stub.contains("a: u32"), "stub leaked the body: {stub:?}");
216    }
217
218    #[test]
219    fn multiline_decorator_prefix_is_kept_whole() {
220        let f = frag(
221            FragmentKind::Function,
222            1,
223            "@retry(\n    times=3,\n)\ndef f(x):\n    one()\n    two()\n",
224        );
225        let stub = stub_of(&f);
226        assert!(stub.starts_with("@retry("), "lost the decorator: {stub:?}");
227        assert!(
228            stub.contains("times=3"),
229            "truncated the decorator: {stub:?}"
230        );
231        assert!(stub.contains("def f(x):"), "lost the signature: {stub:?}");
232        assert!(!stub.contains("one()"), "stub leaked the body: {stub:?}");
233    }
234
235    #[test]
236    fn signature_span_matches_the_emitted_line_count() {
237        // sig_end_line is derived arithmetically from start_line; if it drifts,
238        // the stub's FragmentId claims lines it does not contain and the
239        // interval index deduplicates against the wrong span.
240        let f = frag(
241            FragmentKind::Function,
242            42,
243            "def f(\n    x,\n):\n    one()\n    two()\n    three()\n",
244        );
245        let sigs = generate_signature_variants(std::slice::from_ref(&f));
246        let sig = &sigs[0];
247        assert_eq!(sig.id.start_line, 42);
248        assert_eq!(
249            sig.id.end_line - sig.id.start_line + 1,
250            sig.content.lines().count() as u32
251        );
252    }
253
254    #[test]
255    fn kind_maps_to_its_signature_variant_and_ineligible_kinds_are_skipped() {
256        let body = "\nline\nline\nline\nline\nline\n";
257        for (kind, expected) in [
258            (FragmentKind::Function, FragmentKind::FunctionSignature),
259            (FragmentKind::Class, FragmentKind::ClassSignature),
260            (FragmentKind::Struct, FragmentKind::StructSignature),
261            (FragmentKind::Interface, FragmentKind::InterfaceSignature),
262            (FragmentKind::Enum, FragmentKind::EnumSignature),
263            // #106: TS/JS arrow bindings must get a stub or a large changed
264            // arrow function disappears from the output entirely.
265            (FragmentKind::Variable, FragmentKind::FunctionSignature),
266        ] {
267            let sigs = generate_signature_variants(&[frag(kind, 1, body)]);
268            assert_eq!(sigs.len(), 1, "{kind:?} produced no signature");
269            assert_eq!(sigs[0].kind, expected, "{kind:?} mapped wrong");
270        }
271        assert!(generate_signature_variants(&[frag(FragmentKind::Chunk, 1, body)]).is_empty());
272        assert!(generate_signature_variants(&[frag(FragmentKind::Module, 1, body)]).is_empty());
273    }
274
275    #[test]
276    fn fragments_shorter_than_the_threshold_get_no_stub() {
277        let short = frag(FragmentKind::Function, 1, "def f():\n    one()\n");
278        assert!(generate_signature_variants(&[short]).is_empty());
279    }
280
281    #[test]
282    fn duplicate_signature_spans_are_emitted_once() {
283        let a = frag(
284            FragmentKind::Function,
285            1,
286            "def f(x):\n    a()\n    b()\n    c()\n    d()\n",
287        );
288        let b = frag(
289            FragmentKind::Function,
290            1,
291            "def f(x):\n    a()\n    b()\n    c()\n    e()\n",
292        );
293        assert_eq!(generate_signature_variants(&[a, b]).len(), 1);
294    }
295
296    #[test]
297    fn signatureless_content_falls_back_to_a_bounded_prefix() {
298        // No parens and no brace at all: the fallback must stay inside the
299        // fragment rather than claiming the whole body.
300        let f = frag(
301            FragmentKind::Class,
302            1,
303            "class C:\n    x = 1\n    y = 2\n    z = 3\n    w = 4\n",
304        );
305        let stub = stub_of(&f);
306        assert!(
307            stub.lines().count() <= 2,
308            "fallback took too much: {stub:?}"
309        );
310        assert!(stub.starts_with("class C:"));
311    }
312}