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        // The eligibility gate above measures the id's line span; everything
123        // below indexes the actual text. A fragment whose span says five lines
124        // while its content holds none would yield `sig_end = 0`, hence an id
125        // whose end precedes its start — and `line_count()` on that id is a
126        // subtraction overflow for every later stage that touches it.
127        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        // `x={}` puts a brace inside the parameter list; truncating there would
179        // ship a syntactically broken stub.
180        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        // sig_end_line is derived arithmetically from start_line; if it drifts,
246        // the stub's FragmentId claims lines it does not contain and the
247        // interval index deduplicates against the wrong span.
248        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            // #106: TS/JS arrow bindings must get a stub or a large changed
272            // arrow function disappears from the output entirely.
273            (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        // No parens and no brace at all: the fallback must stay inside the
307        // fragment rather than claiming the whole body.
308        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    /// Eligibility is decided from the id's line span, the signature is cut
322    /// from the content. A span claiming lines that the text does not have
323    /// produced an id whose end precedes its start, and `line_count()` on such
324    /// an id is a subtraction overflow in every stage downstream.
325    #[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}