Skip to main content

codelens_engine/
change_signature.rs

1use crate::project::ProjectRoot;
2use crate::rename::{RenameEdit, apply_edits, find_all_word_matches};
3use crate::symbols::{find_symbol, find_symbol_range};
4use anyhow::{Result, bail};
5use serde::{Deserialize, Serialize};
6use std::fs;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ParamSpec {
10    pub name: String,
11    #[serde(rename = "type", default)]
12    pub param_type: Option<String>,
13    #[serde(default)]
14    pub default: Option<String>,
15}
16
17#[derive(Debug, Clone, Serialize)]
18pub struct ChangeSignatureResult {
19    pub success: bool,
20    pub message: String,
21    pub old_params: Vec<String>,
22    pub new_params: Vec<String>,
23    pub call_sites_updated: usize,
24    pub modified_files: Vec<String>,
25    pub edits: Vec<RenameEdit>,
26}
27
28/// Change a function's signature and update all call sites.
29///
30/// Parameters can be reordered, added (with defaults), or removed.
31/// Matching is name-based: parameters with the same name are mapped,
32/// new names are insertions, missing names are deletions.
33pub fn change_signature(
34    project: &ProjectRoot,
35    file_path: &str,
36    function_name: &str,
37    name_path: Option<&str>,
38    new_params: &[ParamSpec],
39    dry_run: bool,
40) -> Result<ChangeSignatureResult> {
41    // 1. Find the function
42    let symbols = find_symbol(project, function_name, Some(file_path), true, true, 1)?;
43    let _sym = symbols.first().ok_or_else(|| {
44        anyhow::anyhow!("Function '{}' not found in '{}'", function_name, file_path)
45    })?;
46
47    let resolved = project.resolve(file_path)?;
48    let source = fs::read_to_string(&resolved)?;
49    let (start_byte, end_byte) = find_symbol_range(project, file_path, function_name, name_path)?;
50    let full_def = &source[start_byte..end_byte];
51
52    // 2. Parse current parameter list
53    let paren_start = full_def
54        .find('(')
55        .ok_or_else(|| anyhow::anyhow!("No parameter list found in function definition"))?;
56    let paren_end = find_matching_paren(full_def, paren_start)?;
57    let old_params_str = &full_def[paren_start + 1..paren_end];
58
59    let ext = std::path::Path::new(file_path)
60        .extension()
61        .and_then(|e| e.to_str())
62        .unwrap_or("");
63
64    let old_param_names = parse_param_names(old_params_str, ext);
65    // Filter out self/this params for mapping
66    let old_mappable: Vec<&str> = old_param_names
67        .iter()
68        .filter(|p| !is_self_param(p))
69        .map(|s| s.as_str())
70        .collect();
71
72    // 3. Build new parameter string for the definition
73    let new_param_string = build_new_param_string(new_params, ext, old_params_str);
74
75    // 4. Build edit for the definition
76    let abs_paren_start = start_byte + paren_start;
77    let _abs_paren_end = start_byte + paren_end;
78
79    // Calculate line/col for definition edit
80    let def_line = source[..abs_paren_start + 1].lines().count();
81    let def_line_start = source[..abs_paren_start + 1]
82        .rfind('\n')
83        .map(|p| p + 1)
84        .unwrap_or(0);
85    let def_col = abs_paren_start + 1 - def_line_start + 1;
86
87    let old_params_text = old_params_str.to_string();
88
89    let mut edits = vec![RenameEdit {
90        file_path: file_path.to_string(),
91        line: def_line,
92        column: def_col,
93        old_text: old_params_text.clone(),
94        new_text: new_param_string.clone(),
95    }];
96
97    // 5. Build parameter index mapping: old position -> new position
98    let param_mapping = build_param_mapping(&old_mappable, new_params);
99
100    // 6. Find and update call sites
101    let matches = find_all_word_matches(project, function_name)?;
102    let sym_line = _sym.line;
103
104    let mut call_sites_updated = 0;
105
106    for (ref_file, line, col) in &matches {
107        // Skip the definition itself
108        if ref_file == file_path && *line == sym_line {
109            continue;
110        }
111
112        let ref_resolved = match project.resolve(ref_file) {
113            Ok(p) => p,
114            Err(_) => continue,
115        };
116        let ref_content = match fs::read_to_string(&ref_resolved) {
117            Ok(c) => c,
118            Err(_) => continue,
119        };
120        let ref_lines: Vec<&str> = ref_content.lines().collect();
121        if *line == 0 || *line > ref_lines.len() {
122            continue;
123        }
124        let line_text = ref_lines[*line - 1];
125
126        // Check if this is a call site (followed by '(')
127        let name_end = *col - 1 + function_name.len();
128        if name_end >= line_text.len() {
129            continue;
130        }
131        let after = line_text[name_end..].trim_start();
132        if !after.starts_with('(') {
133            continue;
134        }
135
136        // Extract call arguments
137        let call_rest = &line_text[*col - 1..];
138        let call_paren = match call_rest.find('(') {
139            Some(p) => p,
140            None => continue,
141        };
142        let call_paren_end = match find_matching_paren(call_rest, call_paren) {
143            Ok(p) => p,
144            Err(_) => continue,
145        };
146        let args_str = &call_rest[call_paren + 1..call_paren_end];
147        let old_args = split_args(args_str);
148
149        // Build new arguments based on mapping
150        let new_args = build_new_args(&old_args, &param_mapping, new_params);
151        let new_args_str = new_args.join(", ");
152
153        if args_str.trim() != new_args_str.trim() {
154            let args_col = *col + call_paren + 1;
155            edits.push(RenameEdit {
156                file_path: ref_file.clone(),
157                line: *line,
158                column: args_col,
159                old_text: args_str.to_string(),
160                new_text: new_args_str,
161            });
162            call_sites_updated += 1;
163        }
164    }
165
166    let mut modified_files: Vec<String> = edits.iter().map(|e| e.file_path.clone()).collect();
167    modified_files.sort();
168    modified_files.dedup();
169
170    let result = ChangeSignatureResult {
171        success: true,
172        message: format!(
173            "Changed signature of '{}': {} params → {}, updated {} call site(s)",
174            function_name,
175            old_mappable.len(),
176            new_params.len(),
177            call_sites_updated
178        ),
179        old_params: old_mappable.iter().map(|s| s.to_string()).collect(),
180        new_params: new_params.iter().map(|p| p.name.clone()).collect(),
181        call_sites_updated,
182        modified_files,
183        edits: edits.clone(),
184    };
185
186    if !dry_run {
187        apply_edits(project, &edits)?;
188    }
189
190    Ok(result)
191}
192
193/// Parse parameter names from a parameter string.
194fn parse_param_names(params_str: &str, ext: &str) -> Vec<String> {
195    if params_str.trim().is_empty() {
196        return vec![];
197    }
198    params_str
199        .split(',')
200        .map(|p| {
201            let p = p.trim();
202            // Remove default values
203            let p = p.split('=').next().unwrap_or(p).trim();
204            match ext {
205                "rs" => p.split(':').next().unwrap_or(p).trim().to_string(),
206                "go" => p.split_whitespace().next().unwrap_or(p).to_string(),
207                "py" => {
208                    if p.contains(':') {
209                        p.split(':').next().unwrap_or(p).trim().to_string()
210                    } else {
211                        p.to_string()
212                    }
213                }
214                _ => {
215                    if p.contains(':') {
216                        p.split(':').next().unwrap_or(p).trim().to_string()
217                    } else {
218                        p.split_whitespace().last().unwrap_or(p).to_string()
219                    }
220                }
221            }
222        })
223        .collect()
224}
225
226fn is_self_param(name: &str) -> bool {
227    matches!(name, "self" | "&self" | "&mut self" | "this")
228}
229
230/// Build parameter index mapping: for each new param, find its old index (if it existed).
231fn build_param_mapping(old_params: &[&str], new_params: &[ParamSpec]) -> Vec<Option<usize>> {
232    new_params
233        .iter()
234        .map(|np| old_params.iter().position(|&op| op == np.name))
235        .collect()
236}
237
238/// Build new parameter string for the function definition.
239fn build_new_param_string(new_params: &[ParamSpec], ext: &str, old_params_str: &str) -> String {
240    // Preserve self parameter if present
241    let old_parts: Vec<&str> = old_params_str.split(',').map(|p| p.trim()).collect();
242    let has_self = old_parts
243        .first()
244        .is_some_and(|p| is_self_param(p.split(':').next().unwrap_or(p).trim()));
245
246    let mut parts = Vec::new();
247    if has_self {
248        parts.push(old_parts[0].to_string());
249    }
250
251    for param in new_params {
252        let part = match ext {
253            "rs" => {
254                if let Some(t) = &param.param_type {
255                    format!("{}: {}", param.name, t)
256                } else {
257                    param.name.clone()
258                }
259            }
260            "py" => {
261                let mut s = param.name.clone();
262                if let Some(t) = &param.param_type {
263                    s = format!("{}: {}", s, t);
264                }
265                if let Some(d) = &param.default {
266                    s = format!("{} = {}", s, d);
267                }
268                s
269            }
270            "go" => {
271                if let Some(t) = &param.param_type {
272                    format!("{} {}", param.name, t)
273                } else {
274                    param.name.clone()
275                }
276            }
277            "ts" | "tsx" | "js" | "jsx" => {
278                let mut s = param.name.clone();
279                if let Some(t) = &param.param_type {
280                    s = format!("{}: {}", s, t);
281                }
282                if let Some(d) = &param.default {
283                    s = format!("{} = {}", s, d);
284                }
285                s
286            }
287            _ => {
288                if let Some(t) = &param.param_type {
289                    format!("{} {}", t, param.name)
290                } else {
291                    param.name.clone()
292                }
293            }
294        };
295        parts.push(part);
296    }
297
298    parts.join(", ")
299}
300
301/// Build new argument list for a call site based on the parameter mapping.
302fn build_new_args(
303    old_args: &[String],
304    mapping: &[Option<usize>],
305    new_params: &[ParamSpec],
306) -> Vec<String> {
307    mapping
308        .iter()
309        .zip(new_params.iter())
310        .map(|(old_idx, param)| {
311            if let Some(idx) = old_idx {
312                // Existing parameter: use the old argument
313                old_args.get(*idx).cloned().unwrap_or_else(|| {
314                    param
315                        .default
316                        .clone()
317                        .unwrap_or_else(|| format!("/* {} */", param.name))
318                })
319            } else {
320                // New parameter: use default value or placeholder
321                param
322                    .default
323                    .clone()
324                    .unwrap_or_else(|| format!("/* {} */", param.name))
325            }
326        })
327        .collect()
328}
329
330fn find_matching_paren(s: &str, open_pos: usize) -> Result<usize> {
331    let mut depth = 0;
332    for (i, ch) in s[open_pos..].char_indices() {
333        match ch {
334            '(' => depth += 1,
335            ')' => {
336                depth -= 1;
337                if depth == 0 {
338                    return Ok(open_pos + i);
339                }
340            }
341            _ => {}
342        }
343    }
344    bail!("Unmatched parenthesis")
345}
346
347fn split_args(s: &str) -> Vec<String> {
348    if s.trim().is_empty() {
349        return vec![];
350    }
351    let mut args = Vec::new();
352    let mut depth = 0;
353    let mut current = String::new();
354    for ch in s.chars() {
355        match ch {
356            '(' | '[' | '{' => {
357                depth += 1;
358                current.push(ch);
359            }
360            ')' | ']' | '}' => {
361                depth -= 1;
362                current.push(ch);
363            }
364            ',' if depth == 0 => {
365                args.push(current.trim().to_string());
366                current.clear();
367            }
368            _ => current.push(ch),
369        }
370    }
371    if !current.trim().is_empty() {
372        args.push(current.trim().to_string());
373    }
374    args
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn test_parse_param_names_rust() {
383        let names = parse_param_names("a: i32, b: String, c: &str", "rs");
384        assert_eq!(names, vec!["a", "b", "c"]);
385    }
386
387    #[test]
388    fn test_parse_param_names_python() {
389        let names = parse_param_names("self, x, y: int, z=10", "py");
390        assert_eq!(names, vec!["self", "x", "y", "z"]);
391    }
392
393    #[test]
394    fn test_parse_param_names_go() {
395        let names = parse_param_names("x int, y string", "go");
396        assert_eq!(names, vec!["x", "y"]);
397    }
398
399    #[test]
400    fn test_build_param_mapping() {
401        let old = vec!["a", "b", "c"];
402        let new_params = vec![
403            ParamSpec {
404                name: "c".into(),
405                param_type: None,
406                default: None,
407            },
408            ParamSpec {
409                name: "a".into(),
410                param_type: None,
411                default: None,
412            },
413            ParamSpec {
414                name: "d".into(),
415                param_type: None,
416                default: Some("0".into()),
417            },
418        ];
419        let mapping = build_param_mapping(&old, &new_params);
420        assert_eq!(mapping, vec![Some(2), Some(0), None]);
421    }
422
423    #[test]
424    fn test_build_new_args() {
425        let old_args = vec!["1".into(), "2".into(), "3".into()];
426        let new_params = vec![
427            ParamSpec {
428                name: "c".into(),
429                param_type: None,
430                default: None,
431            },
432            ParamSpec {
433                name: "a".into(),
434                param_type: None,
435                default: None,
436            },
437            ParamSpec {
438                name: "d".into(),
439                param_type: None,
440                default: Some("0".into()),
441            },
442        ];
443        let mapping = vec![Some(2), Some(0), None];
444        let result = build_new_args(&old_args, &mapping, &new_params);
445        assert_eq!(result, vec!["3", "1", "0"]);
446    }
447
448    #[test]
449    fn test_build_new_param_string_rust() {
450        let params = vec![
451            ParamSpec {
452                name: "x".into(),
453                param_type: Some("i32".into()),
454                default: None,
455            },
456            ParamSpec {
457                name: "y".into(),
458                param_type: Some("i32".into()),
459                default: None,
460            },
461        ];
462        let result = build_new_param_string(&params, "rs", "a: i32");
463        assert_eq!(result, "x: i32, y: i32");
464    }
465
466    #[test]
467    fn test_build_new_param_string_preserves_self() {
468        let params = vec![ParamSpec {
469            name: "x".into(),
470            param_type: Some("i32".into()),
471            default: None,
472        }];
473        let result = build_new_param_string(&params, "rs", "&self, a: i32");
474        assert_eq!(result, "&self, x: i32");
475    }
476
477    #[test]
478    fn test_build_new_param_string_python() {
479        let params = vec![
480            ParamSpec {
481                name: "x".into(),
482                param_type: Some("int".into()),
483                default: None,
484            },
485            ParamSpec {
486                name: "y".into(),
487                param_type: None,
488                default: Some("0".into()),
489            },
490        ];
491        let result = build_new_param_string(&params, "py", "a, b");
492        assert_eq!(result, "x: int, y = 0");
493    }
494}