Skip to main content

semver_analyzer_ts/diff_parser/
mod.rs

1//! TypeScript DiffParser implementation.
2//!
3//! Parses git diffs between two refs and extracts all functions whose bodies
4//! changed. This processes SOURCE files (.ts/.tsx), not .d.ts declaration files.
5//!
6//! ## Flow
7//!
8//! 1. `git diff --name-status from_ref..to_ref` → list of changed files
9//! 2. Filter to `.ts`/`.tsx`/`.js`/`.jsx` source files (skip tests, configs, .d.ts)
10//! 3. For each file, `git show from_ref:path` and `git show to_ref:path`
11//! 4. Parse both versions with OXC
12//! 5. Extract all function-like declarations from both ASTs
13//! 6. Match by qualified name, compare bodies
14//! 7. Return `Vec<ChangedFunction>` for all functions with differing bodies
15
16use anyhow::{Context, Result};
17use oxc_allocator::Allocator;
18use oxc_ast::ast::*;
19use oxc_parser::Parser;
20use oxc_span::SourceType;
21use semver_analyzer_core::{ChangedFunction, SymbolKind, Visibility};
22use std::collections::HashMap;
23use std::path::{Path, PathBuf};
24use std::process::Command;
25
26/// TypeScript/JavaScript DiffParser implementation.
27///
28/// Uses git commands to retrieve file versions and OXC to parse source ASTs.
29#[derive(Default)]
30pub struct TsDiffParser;
31
32impl TsDiffParser {
33    pub fn new() -> Self {
34        Self
35    }
36}
37
38impl TsDiffParser {
39    pub fn parse_changed_functions(
40        &self,
41        repo: &Path,
42        from_ref: &str,
43        to_ref: &str,
44    ) -> Result<Vec<ChangedFunction>> {
45        // Step 1: Get list of changed files
46        let changed_files = git_diff_name_status(repo, from_ref, to_ref)?;
47
48        let mut all_changed = Vec::new();
49
50        for (status, file_path, renamed_from) in &changed_files {
51            // Skip non-source files
52            if !is_source_file(file_path) {
53                continue;
54            }
55
56            match status {
57                FileChange::Added => {
58                    // New file: all functions are "added" (no old body)
59                    let new_source = git_show(repo, to_ref, file_path)?;
60                    let new_fns = extract_functions_from_source(&new_source, file_path)?;
61
62                    for func in new_fns {
63                        all_changed.push(ChangedFunction {
64                            qualified_name: func.qualified_name,
65                            name: func.name,
66                            file: file_path.clone(),
67                            line: func.line,
68                            kind: func.kind,
69                            visibility: func.visibility,
70                            old_body: None,
71                            new_body: Some(func.body),
72                            old_signature: None,
73                            new_signature: Some(func.signature),
74                        });
75                    }
76                }
77
78                FileChange::Deleted => {
79                    // Deleted file: all functions are "removed" (no new body)
80                    let old_source = git_show(repo, from_ref, file_path)?;
81                    let old_fns = extract_functions_from_source(&old_source, file_path)?;
82
83                    for func in old_fns {
84                        all_changed.push(ChangedFunction {
85                            qualified_name: func.qualified_name,
86                            name: func.name,
87                            file: file_path.clone(),
88                            line: func.line,
89                            kind: func.kind,
90                            visibility: func.visibility,
91                            old_body: Some(func.body),
92                            new_body: None,
93                            old_signature: Some(func.signature),
94                            new_signature: None,
95                        });
96                    }
97                }
98
99                FileChange::Modified => {
100                    let old_source = git_show(repo, from_ref, file_path)?;
101                    let new_source = git_show(repo, to_ref, file_path)?;
102
103                    let changes = diff_functions_in_file(&old_source, &new_source, file_path)?;
104                    all_changed.extend(changes);
105                }
106
107                FileChange::Renamed => {
108                    // Renamed file: compare old path content with new path content
109                    let old_path = renamed_from.as_ref().unwrap_or(file_path);
110                    let old_source = git_show(repo, from_ref, old_path)?;
111                    let new_source = git_show(repo, to_ref, file_path)?;
112
113                    let changes = diff_functions_in_file(&old_source, &new_source, file_path)?;
114                    all_changed.extend(changes);
115                }
116            }
117        }
118
119        Ok(all_changed)
120    }
121}
122
123// ── Git Operations ──────────────────────────────────────────────────────
124
125/// File change status from `git diff --name-status`.
126#[derive(Debug, Clone, PartialEq, Eq)]
127enum FileChange {
128    Added,
129    Modified,
130    Deleted,
131    Renamed,
132}
133
134/// Parse `git diff --name-status` output to get changed files.
135///
136/// Returns (status, path, optional_renamed_from).
137fn git_diff_name_status(
138    repo: &Path,
139    from_ref: &str,
140    to_ref: &str,
141) -> Result<Vec<(FileChange, PathBuf, Option<PathBuf>)>> {
142    let output = Command::new("git")
143        .args([
144            "diff",
145            "--name-status",
146            "-M30", // Detect renames with 30% similarity
147            &format!("{}..{}", from_ref, to_ref),
148        ])
149        .current_dir(repo)
150        .output()
151        .context("Failed to run git diff")?;
152
153    if !output.status.success() {
154        let stderr = String::from_utf8_lossy(&output.stderr);
155        anyhow::bail!("git diff failed: {}", stderr);
156    }
157
158    let stdout = String::from_utf8_lossy(&output.stdout);
159    let mut results = Vec::new();
160
161    for line in stdout.lines() {
162        let parts: Vec<&str> = line.split('\t').collect();
163        if parts.is_empty() {
164            continue;
165        }
166
167        let status_char = parts[0].chars().next().unwrap_or('?');
168        match status_char {
169            'A' if parts.len() >= 2 => {
170                results.push((FileChange::Added, PathBuf::from(parts[1]), None));
171            }
172            'D' if parts.len() >= 2 => {
173                results.push((FileChange::Deleted, PathBuf::from(parts[1]), None));
174            }
175            'M' if parts.len() >= 2 => {
176                results.push((FileChange::Modified, PathBuf::from(parts[1]), None));
177            }
178            'R' if parts.len() >= 3 => {
179                // R100\told_path\tnew_path
180                results.push((
181                    FileChange::Renamed,
182                    PathBuf::from(parts[2]),
183                    Some(PathBuf::from(parts[1])),
184                ));
185            }
186            _ => {
187                // Skip unknown statuses (Copy, etc.)
188            }
189        }
190    }
191
192    Ok(results)
193}
194
195/// Get a file's content at a specific git ref.
196fn git_show(repo: &Path, git_ref: &str, file_path: &Path) -> Result<String> {
197    let spec = format!("{}:{}", git_ref, file_path.display());
198    let output = Command::new("git")
199        .args(["show", &spec])
200        .current_dir(repo)
201        .output()
202        .with_context(|| format!("Failed to run git show {}", spec))?;
203
204    if !output.status.success() {
205        let stderr = String::from_utf8_lossy(&output.stderr);
206        anyhow::bail!("git show {} failed: {}", spec, stderr);
207    }
208
209    Ok(String::from_utf8_lossy(&output.stdout).to_string())
210}
211
212// ── File Filtering ──────────────────────────────────────────────────────
213
214/// Check if a file is a TypeScript/JavaScript source file worth analyzing.
215///
216/// Excludes:
217/// - `.d.ts` declaration files (TD handles those)
218/// - Test files (TestAnalyzer handles those separately)
219/// - Config files, stories, CSS, docs
220fn is_source_file(path: &Path) -> bool {
221    let path_str = path.to_string_lossy();
222
223    // Must be TS/JS
224    let is_ts_js = path_str.ends_with(".ts")
225        || path_str.ends_with(".tsx")
226        || path_str.ends_with(".js")
227        || path_str.ends_with(".jsx")
228        || path_str.ends_with(".mts")
229        || path_str.ends_with(".mjs");
230
231    if !is_ts_js {
232        return false;
233    }
234
235    // Skip .d.ts files (TD handles them)
236    if path_str.ends_with(".d.ts") || path_str.ends_with(".d.mts") {
237        return false;
238    }
239
240    // Skip dist/ build output directories (TD handles API changes via .d.ts files;
241    // the BU pipeline should only analyze src/ files to avoid duplicate detection)
242    if path_str.contains("/dist/") || path_str.starts_with("dist/") {
243        return false;
244    }
245
246    // Skip test files (TestAnalyzer handles them separately)
247    if is_test_file(path) {
248        return false;
249    }
250
251    // Skip non-source files
252    let skip_patterns = [
253        ".stories.",
254        ".story.",
255        ".config.",
256        ".conf.",
257        "__mocks__/",
258        "__fixtures__/",
259        ".eslintrc",
260        "jest.config",
261        "vitest.config",
262        "webpack.config",
263        "rollup.config",
264        "vite.config",
265        "tsconfig",
266        "package.json",
267    ];
268
269    for pattern in &skip_patterns {
270        if path_str.contains(pattern) {
271            return false;
272        }
273    }
274
275    true
276}
277
278/// Check if a file is a test file.
279pub(crate) fn is_test_file(path: &Path) -> bool {
280    let path_str = path.to_string_lossy();
281    path_str.contains(".test.")
282        || path_str.contains(".spec.")
283        || path_str.contains("__tests__/")
284        || path_str.contains("__test__/")
285        || path_str.ends_with(".test.ts")
286        || path_str.ends_with(".test.tsx")
287        || path_str.ends_with(".spec.ts")
288        || path_str.ends_with(".spec.tsx")
289}
290
291// ── Function Extraction from Source AST ─────────────────────────────────
292
293/// A function-like construct extracted from a source file.
294#[derive(Debug, Clone)]
295struct ExtractedFunction {
296    /// Qualified name: `file_path::ClassName.methodName` or `file_path::functionName`
297    qualified_name: String,
298
299    /// Simple name.
300    name: String,
301
302    /// Line number (1-indexed).
303    line: usize,
304
305    /// Symbol kind.
306    kind: SymbolKind,
307
308    /// Whether this function is exported.
309    visibility: Visibility,
310
311    /// Full function body source text (everything between and including `{ ... }`).
312    body: String,
313
314    /// Function signature (everything before the body).
315    signature: String,
316}
317
318/// Extract all function-like declarations from a TypeScript/JavaScript source file.
319///
320/// Handles:
321/// - `function foo() { ... }` — top-level function declaration
322/// - `export function foo() { ... }` — exported function
323/// - `const foo = () => { ... }` — arrow function assigned to const/let/var
324/// - `const foo = function() { ... }` — function expression assigned to variable
325/// - `class Foo { bar() { ... } }` — class method
326/// - `class Foo { get bar() { ... } }` — getter
327/// - `class Foo { set bar(v) { ... } }` — setter
328/// - `class Foo { constructor() { ... } }` — constructor
329fn extract_functions_from_source(source: &str, file_path: &Path) -> Result<Vec<ExtractedFunction>> {
330    let allocator = Allocator::default();
331    let source_type = SourceType::from_path(file_path).unwrap_or_else(|_| SourceType::tsx());
332
333    let parsed = Parser::new(&allocator, source, source_type).parse();
334    // Don't bail on parse errors — extract what we can from partial ASTs.
335
336    let mut functions = Vec::new();
337    let file_prefix = file_path.to_string_lossy().to_string();
338
339    extract_from_statements(
340        &parsed.program.body,
341        source,
342        &file_prefix,
343        None,  // no class context
344        false, // not exported by default
345        &mut functions,
346    );
347
348    Ok(functions)
349}
350
351/// Recursively extract functions from a list of statements.
352///
353/// `class_name` is set when processing class body methods.
354/// `parent_exported` tracks whether the parent context is exported.
355fn extract_from_statements(
356    stmts: &[Statement<'_>],
357    source: &str,
358    file_prefix: &str,
359    class_name: Option<&str>,
360    parent_exported: bool,
361    out: &mut Vec<ExtractedFunction>,
362) {
363    for stmt in stmts {
364        match stmt {
365            // ── Function declarations ───────────────────────────────
366            Statement::FunctionDeclaration(func) => {
367                if let Some(id) = &func.id {
368                    let name = id.name.to_string();
369                    let qualified = match class_name {
370                        Some(cls) => format!("{}::{}::{}", file_prefix, cls, name),
371                        None => format!("{}::{}", file_prefix, name),
372                    };
373                    let (sig, body) = split_function_sig_body(func, source);
374                    out.push(ExtractedFunction {
375                        qualified_name: qualified,
376                        name,
377                        line: line_number(source, func.span.start as usize),
378                        kind: SymbolKind::Function,
379                        visibility: if parent_exported {
380                            Visibility::Exported
381                        } else {
382                            Visibility::Internal
383                        },
384                        body,
385                        signature: sig,
386                    });
387                }
388            }
389
390            // ── Variable declarations (arrow fns, fn expressions) ───
391            Statement::VariableDeclaration(var_decl) => {
392                for declarator in &var_decl.declarations {
393                    if let Some(init) = &declarator.init {
394                        if let BindingPattern::BindingIdentifier(id) = &declarator.id {
395                            let name = id.name.to_string();
396                            if let Some(func_info) = extract_from_expression(init, source) {
397                                let qualified = match class_name {
398                                    Some(cls) => {
399                                        format!("{}::{}::{}", file_prefix, cls, name)
400                                    }
401                                    None => format!("{}::{}", file_prefix, name),
402                                };
403                                out.push(ExtractedFunction {
404                                    qualified_name: qualified,
405                                    name,
406                                    line: line_number(source, declarator.span.start as usize),
407                                    kind: func_info.kind,
408                                    visibility: if parent_exported {
409                                        Visibility::Exported
410                                    } else {
411                                        Visibility::Internal
412                                    },
413                                    body: func_info.body,
414                                    signature: func_info.sig,
415                                });
416                            }
417                        }
418                    }
419                }
420            }
421
422            // ── Class declarations ──────────────────────────────────
423            Statement::ClassDeclaration(class) => {
424                if let Some(id) = &class.id {
425                    let cls_name = id.name.to_string();
426                    extract_from_class_body(
427                        &class.body,
428                        source,
429                        file_prefix,
430                        &cls_name,
431                        parent_exported,
432                        out,
433                    );
434                }
435            }
436
437            // ── Export named declaration ─────────────────────────────
438            Statement::ExportNamedDeclaration(export) => {
439                if let Some(decl) = &export.declaration {
440                    extract_from_exported_declaration(decl, source, file_prefix, class_name, out);
441                }
442            }
443
444            // ── Export default declaration ───────────────────────────
445            Statement::ExportDefaultDeclaration(export) => match &export.declaration {
446                ExportDefaultDeclarationKind::FunctionDeclaration(func) => {
447                    let name = func
448                        .id
449                        .as_ref()
450                        .map(|id| id.name.to_string())
451                        .unwrap_or_else(|| "default".to_string());
452                    let qualified = format!("{}::{}", file_prefix, name);
453                    let (sig, body) = split_function_sig_body(func, source);
454                    out.push(ExtractedFunction {
455                        qualified_name: qualified,
456                        name,
457                        line: line_number(source, func.span.start as usize),
458                        kind: SymbolKind::Function,
459                        visibility: Visibility::Exported,
460                        body,
461                        signature: sig,
462                    });
463                }
464                ExportDefaultDeclarationKind::ClassDeclaration(class) => {
465                    let cls_name = class
466                        .id
467                        .as_ref()
468                        .map(|id| id.name.to_string())
469                        .unwrap_or_else(|| "default".to_string());
470                    extract_from_class_body(&class.body, source, file_prefix, &cls_name, true, out);
471                }
472                _ => {}
473            },
474
475            _ => {}
476        }
477    }
478}
479
480/// Extract function info from a class body (methods, getters, setters, constructor).
481fn extract_from_class_body(
482    body: &ClassBody<'_>,
483    source: &str,
484    file_prefix: &str,
485    class_name: &str,
486    is_exported: bool,
487    out: &mut Vec<ExtractedFunction>,
488) {
489    for element in &body.body {
490        match element {
491            ClassElement::MethodDefinition(method) => {
492                if method.value.body.is_none() {
493                    continue; // Abstract method or declaration — no body to compare
494                }
495
496                let name = property_key_name(&method.key);
497                let qualified = format!("{}::{}::{}", file_prefix, class_name, name);
498
499                let kind = match method.kind {
500                    MethodDefinitionKind::Constructor => SymbolKind::Constructor,
501                    MethodDefinitionKind::Get => SymbolKind::GetAccessor,
502                    MethodDefinitionKind::Set => SymbolKind::SetAccessor,
503                    MethodDefinitionKind::Method => SymbolKind::Method,
504                };
505
506                let visibility = if method.accessibility == Some(TSAccessibility::Private) {
507                    Visibility::Private
508                } else if is_exported {
509                    Visibility::Exported
510                } else {
511                    Visibility::Public
512                };
513
514                let (sig, body) = split_function_sig_body(&method.value, source);
515
516                out.push(ExtractedFunction {
517                    qualified_name: qualified,
518                    name,
519                    line: line_number(source, method.span.start as usize),
520                    kind,
521                    visibility,
522                    body,
523                    signature: sig,
524                });
525            }
526
527            ClassElement::PropertyDefinition(prop) => {
528                // Check for arrow functions assigned to class properties
529                // e.g., `handleClick = () => { ... }`
530                if let Some(value) = &prop.value {
531                    if let Some(func_info) = extract_from_expression(value, source) {
532                        let name = property_key_name(&prop.key);
533                        let qualified = format!("{}::{}::{}", file_prefix, class_name, name);
534
535                        let visibility = if prop.accessibility == Some(TSAccessibility::Private) {
536                            Visibility::Private
537                        } else if is_exported {
538                            Visibility::Exported
539                        } else {
540                            Visibility::Public
541                        };
542
543                        out.push(ExtractedFunction {
544                            qualified_name: qualified,
545                            name,
546                            line: line_number(source, prop.span.start as usize),
547                            kind: func_info.kind,
548                            visibility,
549                            body: func_info.body,
550                            signature: func_info.sig,
551                        });
552                    }
553                }
554            }
555
556            _ => {}
557        }
558    }
559}
560
561/// Info extracted from a function-like expression (arrow function or function expression).
562struct FuncExprInfo {
563    kind: SymbolKind,
564    sig: String,
565    body: String,
566}
567
568/// Try to extract function info from an expression (arrow function, function expression).
569fn extract_from_expression<'a>(expr: &'a Expression<'a>, source: &str) -> Option<FuncExprInfo> {
570    match expr {
571        Expression::ArrowFunctionExpression(arrow) => {
572            let body_span = arrow.body.span;
573            let body_str = source[body_span.start as usize..body_span.end as usize].to_string();
574
575            // Signature: everything from arrow start to body start
576            let sig_end = body_span.start as usize;
577            let sig_start = arrow.span.start as usize;
578            let sig = source[sig_start..sig_end].trim_end().to_string();
579
580            Some(FuncExprInfo {
581                kind: SymbolKind::Function,
582                sig,
583                body: body_str,
584            })
585        }
586
587        Expression::FunctionExpression(func) => {
588            let (sig, body) = split_function_sig_body(func, source);
589            Some(FuncExprInfo {
590                kind: SymbolKind::Function,
591                sig,
592                body,
593            })
594        }
595
596        // Handle `as` type assertions wrapping arrows: `(() => {}) as Handler`
597        Expression::TSAsExpression(ts_as) => extract_from_expression(&ts_as.expression, source),
598
599        // Handle satisfies: `(() => {}) satisfies Handler`
600        Expression::TSSatisfiesExpression(ts_sat) => {
601            extract_from_expression(&ts_sat.expression, source)
602        }
603
604        // Handle parenthesized: `((() => {}))`
605        Expression::ParenthesizedExpression(paren) => {
606            extract_from_expression(&paren.expression, source)
607        }
608
609        // Handle HOC wrappers: `React.forwardRef((...) => ...)`, `memo((...) => ...)`,
610        // `observer(...)`, `styled(...)`, `connect(...)(...) => ...`, etc.
611        // Extract the first argument if it's a function expression.
612        Expression::CallExpression(call) => {
613            for arg in &call.arguments {
614                if let Argument::ArrowFunctionExpression(arrow) = arg {
615                    let body_span = arrow.body.span;
616                    let body_str =
617                        source[body_span.start as usize..body_span.end as usize].to_string();
618                    let sig_end = body_span.start as usize;
619                    let sig_start = arrow.span.start as usize;
620                    let sig = source[sig_start..sig_end].trim_end().to_string();
621                    return Some(FuncExprInfo {
622                        kind: SymbolKind::Function,
623                        sig,
624                        body: body_str,
625                    });
626                }
627                if let Argument::FunctionExpression(func) = arg {
628                    let (sig, body) = split_function_sig_body(func, source);
629                    return Some(FuncExprInfo {
630                        kind: SymbolKind::Function,
631                        sig,
632                        body,
633                    });
634                }
635            }
636            None
637        }
638
639        _ => None,
640    }
641}
642
643// ── Function Body/Signature Splitting ───────────────────────────────────
644
645/// Split a function into its signature (everything before `{`) and body (`{ ... }`).
646fn split_function_sig_body(func: &Function<'_>, source: &str) -> (String, String) {
647    match &func.body {
648        Some(body) => {
649            let body_span = body.span;
650            let body_str = source[body_span.start as usize..body_span.end as usize].to_string();
651
652            // Signature: from function start to body start
653            let sig_start = func.span.start as usize;
654            let sig_end = body_span.start as usize;
655            let sig = source[sig_start..sig_end].trim_end().to_string();
656
657            (sig, body_str)
658        }
659        None => {
660            // No body (declaration only)
661            let full = source[func.span.start as usize..func.span.end as usize].to_string();
662            (full, String::new())
663        }
664    }
665}
666
667// ── Cross-Version Comparison ────────────────────────────────────────────
668
669/// Compare functions from two versions of the same file.
670///
671/// Matches functions by qualified name. Returns `ChangedFunction` entries
672/// for functions that:
673/// - Exist in both versions with different bodies (modified)
674/// - Exist only in old (removed)
675/// - Exist only in new (added)
676fn diff_functions_in_file(
677    old_source: &str,
678    new_source: &str,
679    file_path: &Path,
680) -> Result<Vec<ChangedFunction>> {
681    let old_fns = extract_functions_from_source(old_source, file_path)?;
682    let new_fns = extract_functions_from_source(new_source, file_path)?;
683
684    let old_map: HashMap<&str, &ExtractedFunction> = old_fns
685        .iter()
686        .map(|f| (f.qualified_name.as_str(), f))
687        .collect();
688    let new_map: HashMap<&str, &ExtractedFunction> = new_fns
689        .iter()
690        .map(|f| (f.qualified_name.as_str(), f))
691        .collect();
692
693    let mut changes = Vec::new();
694
695    // Check for modified and removed functions
696    for (qname, old_fn) in &old_map {
697        if let Some(new_fn) = new_map.get(qname) {
698            // Both versions exist — compare bodies
699            let old_body_normalized = normalize_body(&old_fn.body);
700            let new_body_normalized = normalize_body(&new_fn.body);
701
702            if old_body_normalized != new_body_normalized {
703                changes.push(ChangedFunction {
704                    qualified_name: qname.to_string(),
705                    name: new_fn.name.clone(),
706                    file: file_path.to_path_buf(),
707                    line: new_fn.line,
708                    kind: new_fn.kind,
709                    visibility: new_fn.visibility,
710                    old_body: Some(old_fn.body.clone()),
711                    new_body: Some(new_fn.body.clone()),
712                    old_signature: Some(old_fn.signature.clone()),
713                    new_signature: Some(new_fn.signature.clone()),
714                });
715            }
716        } else {
717            // Function removed
718            changes.push(ChangedFunction {
719                qualified_name: qname.to_string(),
720                name: old_fn.name.clone(),
721                file: file_path.to_path_buf(),
722                line: old_fn.line,
723                kind: old_fn.kind,
724                visibility: old_fn.visibility,
725                old_body: Some(old_fn.body.clone()),
726                new_body: None,
727                old_signature: Some(old_fn.signature.clone()),
728                new_signature: None,
729            });
730        }
731    }
732
733    // Check for added functions (in new but not in old)
734    for (qname, new_fn) in &new_map {
735        if !old_map.contains_key(qname) {
736            changes.push(ChangedFunction {
737                qualified_name: qname.to_string(),
738                name: new_fn.name.clone(),
739                file: file_path.to_path_buf(),
740                line: new_fn.line,
741                kind: new_fn.kind,
742                visibility: new_fn.visibility,
743                old_body: None,
744                new_body: Some(new_fn.body.clone()),
745                old_signature: None,
746                new_signature: Some(new_fn.signature.clone()),
747            });
748        }
749    }
750
751    Ok(changes)
752}
753
754/// Normalize a function body for comparison.
755///
756/// Strips:
757/// - Leading/trailing whitespace on each line
758/// - Empty lines
759/// - Single-line and multi-line comments
760///
761/// This reduces false positives from formatting-only changes.
762fn normalize_body(body: &str) -> String {
763    let mut result = String::new();
764    let mut in_block_comment = false;
765
766    for line in body.lines() {
767        let trimmed = line.trim();
768
769        if in_block_comment {
770            if let Some(pos) = trimmed.find("*/") {
771                // End of block comment — keep rest of line if any
772                let after = trimmed[pos + 2..].trim();
773                if !after.is_empty() {
774                    result.push_str(after);
775                    result.push('\n');
776                }
777                in_block_comment = false;
778            }
779            continue;
780        }
781
782        // Skip single-line comments
783        if trimmed.starts_with("//") {
784            continue;
785        }
786
787        // Handle block comment start
788        if trimmed.contains("/*") {
789            if let Some(start_pos) = trimmed.find("/*") {
790                let before = trimmed[..start_pos].trim();
791                if !before.is_empty() {
792                    result.push_str(before);
793                    result.push('\n');
794                }
795                if trimmed[start_pos..].contains("*/") {
796                    // Block comment starts and ends on same line
797                    if let Some(end_pos) = trimmed[start_pos..].find("*/") {
798                        let after = trimmed[start_pos + end_pos + 2..].trim();
799                        if !after.is_empty() {
800                            result.push_str(after);
801                            result.push('\n');
802                        }
803                    }
804                } else {
805                    in_block_comment = true;
806                }
807                continue;
808            }
809        }
810
811        if !trimmed.is_empty() {
812            result.push_str(trimmed);
813            result.push('\n');
814        }
815    }
816
817    result
818}
819
820// ── Helpers ─────────────────────────────────────────────────────────────
821
822/// Get the name from a property key (used for class methods).
823fn property_key_name(key: &PropertyKey<'_>) -> String {
824    match key {
825        PropertyKey::StaticIdentifier(id) => id.name.to_string(),
826        PropertyKey::PrivateIdentifier(id) => format!("#{}", id.name),
827        _ => "<computed>".to_string(),
828    }
829}
830
831/// Convert a byte offset to a 1-indexed line number.
832fn line_number(source: &str, byte_offset: usize) -> usize {
833    source[..byte_offset.min(source.len())]
834        .chars()
835        .filter(|&c| c == '\n')
836        .count()
837        + 1
838}
839
840/// Process an exported declaration directly (since we can't easily convert
841/// Declaration to Statement in OXC's type system).
842fn extract_from_exported_declaration<'a>(
843    decl: &'a Declaration<'a>,
844    source: &str,
845    file_prefix: &str,
846    class_name: Option<&str>,
847    out: &mut Vec<ExtractedFunction>,
848) {
849    match decl {
850        Declaration::FunctionDeclaration(func) => {
851            if let Some(id) = &func.id {
852                let name = id.name.to_string();
853                let qualified = match class_name {
854                    Some(cls) => format!("{}::{}::{}", file_prefix, cls, name),
855                    None => format!("{}::{}", file_prefix, name),
856                };
857                let (sig, body) = split_function_sig_body(func, source);
858                out.push(ExtractedFunction {
859                    qualified_name: qualified,
860                    name,
861                    line: line_number(source, func.span.start as usize),
862                    kind: SymbolKind::Function,
863                    visibility: Visibility::Exported,
864                    body,
865                    signature: sig,
866                });
867            }
868        }
869
870        Declaration::VariableDeclaration(var_decl) => {
871            for declarator in &var_decl.declarations {
872                if let Some(init) = &declarator.init {
873                    if let BindingPattern::BindingIdentifier(id) = &declarator.id {
874                        let name = id.name.to_string();
875                        if let Some(func_info) = extract_from_expression(init, source) {
876                            let qualified = match class_name {
877                                Some(cls) => format!("{}::{}::{}", file_prefix, cls, name),
878                                None => format!("{}::{}", file_prefix, name),
879                            };
880                            out.push(ExtractedFunction {
881                                qualified_name: qualified,
882                                name,
883                                line: line_number(source, declarator.span.start as usize),
884                                kind: func_info.kind,
885                                visibility: Visibility::Exported,
886                                body: func_info.body,
887                                signature: func_info.sig,
888                            });
889                        }
890                    }
891                }
892            }
893        }
894
895        Declaration::ClassDeclaration(class) => {
896            if let Some(id) = &class.id {
897                let cls_name = id.name.to_string();
898                extract_from_class_body(&class.body, source, file_prefix, &cls_name, true, out);
899            }
900        }
901
902        _ => {}
903    }
904}
905
906#[cfg(test)]
907mod tests {
908    use super::*;
909
910    // ── normalize_body tests ────────────────────────────────────────
911
912    #[test]
913    fn normalize_strips_comments_and_whitespace() {
914        let body = r#"{
915  // This is a comment
916  const x = 1;
917  /* block comment */
918  return x + 1;
919}"#;
920        let normalized = normalize_body(body);
921        assert_eq!(normalized, "{\nconst x = 1;\nreturn x + 1;\n}\n");
922    }
923
924    #[test]
925    fn normalize_strips_multiline_block_comments() {
926        let body = r#"{
927  const x = 1;
928  /*
929   * Multi-line
930   * comment
931   */
932  return x;
933}"#;
934        let normalized = normalize_body(body);
935        assert_eq!(normalized, "{\nconst x = 1;\nreturn x;\n}\n");
936    }
937
938    #[test]
939    fn normalize_identical_bodies_match() {
940        let body1 = r#"{
941    const x = 1;
942    return x;
943  }"#;
944        let body2 = r#"{
945  const x = 1;
946  return x;
947}"#;
948        assert_eq!(normalize_body(body1), normalize_body(body2));
949    }
950
951    #[test]
952    fn normalize_different_bodies_differ() {
953        let body1 = "{ return x + 1; }";
954        let body2 = "{ return x + 2; }";
955        assert_ne!(normalize_body(body1), normalize_body(body2));
956    }
957
958    // ── is_source_file tests ────────────────────────────────────────
959
960    #[test]
961    fn source_file_accepts_ts() {
962        assert!(is_source_file(Path::new("src/api/users.ts")));
963        assert!(is_source_file(Path::new("src/components/Button.tsx")));
964        assert!(is_source_file(Path::new("src/utils.js")));
965        assert!(is_source_file(Path::new("src/app.jsx")));
966        assert!(is_source_file(Path::new("src/lib.mts")));
967    }
968
969    #[test]
970    fn source_file_rejects_dts() {
971        assert!(!is_source_file(Path::new("dist/api/users.d.ts")));
972        assert!(!is_source_file(Path::new("types/index.d.mts")));
973    }
974
975    #[test]
976    fn source_file_rejects_tests() {
977        assert!(!is_source_file(Path::new("src/api/users.test.ts")));
978        assert!(!is_source_file(Path::new("src/api/users.spec.tsx")));
979        assert!(!is_source_file(Path::new("src/__tests__/users.ts")));
980    }
981
982    #[test]
983    fn source_file_rejects_configs() {
984        assert!(!is_source_file(Path::new("jest.config.ts")));
985        assert!(!is_source_file(Path::new("vitest.config.ts")));
986        assert!(!is_source_file(Path::new("webpack.config.js")));
987        assert!(!is_source_file(Path::new("tsconfig.json")));
988    }
989
990    #[test]
991    fn source_file_rejects_dist() {
992        assert!(!is_source_file(Path::new(
993            "packages/react-core/dist/esm/components/Button/Button.tsx"
994        )));
995        assert!(!is_source_file(Path::new(
996            "packages/react-core/dist/js/index.ts"
997        )));
998        assert!(!is_source_file(Path::new("dist/components/Card.tsx")));
999    }
1000
1001    #[test]
1002    fn source_file_rejects_non_js() {
1003        assert!(!is_source_file(Path::new("src/styles.css")));
1004        assert!(!is_source_file(Path::new("README.md")));
1005        assert!(!is_source_file(Path::new("package.json")));
1006    }
1007
1008    // ── is_test_file tests ──────────────────────────────────────────
1009
1010    #[test]
1011    fn test_file_detection() {
1012        assert!(is_test_file(Path::new("src/api/users.test.ts")));
1013        assert!(is_test_file(Path::new("src/api/users.spec.tsx")));
1014        assert!(is_test_file(Path::new("src/__tests__/users.ts")));
1015        assert!(!is_test_file(Path::new("src/api/users.ts")));
1016    }
1017
1018    // ── Function extraction tests ───────────────────────────────────
1019
1020    #[test]
1021    fn extract_top_level_function() {
1022        let source = r#"
1023function createUser(email: string): User {
1024  return db.insert(email);
1025}
1026"#;
1027        let fns = extract_functions_from_source(source, Path::new("src/api.ts")).unwrap();
1028        assert_eq!(fns.len(), 1);
1029        assert_eq!(fns[0].name, "createUser");
1030        assert_eq!(fns[0].qualified_name, "src/api.ts::createUser");
1031        assert_eq!(fns[0].kind, SymbolKind::Function);
1032        assert_eq!(fns[0].visibility, Visibility::Internal);
1033        assert!(fns[0].body.contains("db.insert(email)"));
1034        assert!(fns[0].signature.contains("createUser"));
1035    }
1036
1037    #[test]
1038    fn extract_exported_function() {
1039        let source = r#"
1040export function validate(input: string): boolean {
1041  return input.length > 0;
1042}
1043"#;
1044        let fns = extract_functions_from_source(source, Path::new("src/utils.ts")).unwrap();
1045        assert_eq!(fns.len(), 1);
1046        assert_eq!(fns[0].visibility, Visibility::Exported);
1047    }
1048
1049    #[test]
1050    fn extract_arrow_function_const() {
1051        let source = r#"
1052const handler = (req: Request): Response => {
1053  return new Response("ok");
1054};
1055"#;
1056        let fns = extract_functions_from_source(source, Path::new("src/handler.ts")).unwrap();
1057        assert_eq!(fns.len(), 1);
1058        assert_eq!(fns[0].name, "handler");
1059        assert_eq!(fns[0].kind, SymbolKind::Function);
1060        assert!(fns[0].body.contains("new Response"));
1061    }
1062
1063    #[test]
1064    fn extract_exported_arrow_function() {
1065        let source = r#"
1066export const greet = (name: string): string => {
1067  return `Hello, ${name}!`;
1068};
1069"#;
1070        let fns = extract_functions_from_source(source, Path::new("src/greet.ts")).unwrap();
1071        assert_eq!(fns.len(), 1);
1072        assert_eq!(fns[0].name, "greet");
1073        assert_eq!(fns[0].visibility, Visibility::Exported);
1074    }
1075
1076    #[test]
1077    fn extract_class_methods() {
1078        let source = r#"
1079class UserService {
1080  constructor(private db: Database) {}
1081
1082  async createUser(email: string): Promise<User> {
1083    return this.db.insert(email);
1084  }
1085
1086  private validate(email: string): boolean {
1087    return email.includes("@");
1088  }
1089
1090  get count(): number {
1091    return this.db.count();
1092  }
1093}
1094"#;
1095        let fns = extract_functions_from_source(source, Path::new("src/service.ts")).unwrap();
1096        assert_eq!(fns.len(), 4); // constructor, createUser, validate, count
1097
1098        let constructor = fns.iter().find(|f| f.name == "constructor").unwrap();
1099        assert_eq!(constructor.kind, SymbolKind::Constructor);
1100
1101        let create = fns.iter().find(|f| f.name == "createUser").unwrap();
1102        assert_eq!(create.kind, SymbolKind::Method);
1103        assert!(create.body.contains("this.db.insert"));
1104
1105        let validate = fns.iter().find(|f| f.name == "validate").unwrap();
1106        assert_eq!(validate.visibility, Visibility::Private);
1107
1108        let count = fns.iter().find(|f| f.name == "count").unwrap();
1109        assert_eq!(count.kind, SymbolKind::GetAccessor);
1110    }
1111
1112    #[test]
1113    fn extract_exported_class_methods() {
1114        let source = r#"
1115export class Validator {
1116  check(input: string): boolean {
1117    return input.length > 0;
1118  }
1119}
1120"#;
1121        let fns = extract_functions_from_source(source, Path::new("src/validator.ts")).unwrap();
1122        assert_eq!(fns.len(), 1);
1123        assert_eq!(fns[0].name, "check");
1124        assert_eq!(fns[0].visibility, Visibility::Exported);
1125        assert_eq!(fns[0].qualified_name, "src/validator.ts::Validator::check");
1126    }
1127
1128    #[test]
1129    fn extract_default_exported_function() {
1130        let source = r#"
1131export default function main(): void {
1132  console.log("hello");
1133}
1134"#;
1135        let fns = extract_functions_from_source(source, Path::new("src/main.ts")).unwrap();
1136        assert_eq!(fns.len(), 1);
1137        assert_eq!(fns[0].name, "main");
1138        assert_eq!(fns[0].visibility, Visibility::Exported);
1139    }
1140
1141    #[test]
1142    fn extract_class_property_arrow() {
1143        let source = r#"
1144class Component {
1145  handleClick = () => {
1146    this.setState({ clicked: true });
1147  };
1148}
1149"#;
1150        let fns = extract_functions_from_source(source, Path::new("src/component.tsx")).unwrap();
1151        assert_eq!(fns.len(), 1);
1152        assert_eq!(fns[0].name, "handleClick");
1153        assert!(fns[0].body.contains("setState"));
1154    }
1155
1156    #[test]
1157    fn extract_multiple_functions() {
1158        let source = r#"
1159export function foo(): void {
1160  console.log("foo");
1161}
1162
1163function bar(): void {
1164  console.log("bar");
1165}
1166
1167const baz = (): void => {
1168  console.log("baz");
1169};
1170"#;
1171        let fns = extract_functions_from_source(source, Path::new("src/multi.ts")).unwrap();
1172        assert_eq!(fns.len(), 3);
1173
1174        let names: Vec<&str> = fns.iter().map(|f| f.name.as_str()).collect();
1175        assert!(names.contains(&"foo"));
1176        assert!(names.contains(&"bar"));
1177        assert!(names.contains(&"baz"));
1178    }
1179
1180    // ── Cross-version diffing tests ─────────────────────────────────
1181
1182    #[test]
1183    fn diff_detects_body_change() {
1184        let old = r#"
1185function greet(name: string): string {
1186  return "Hello, " + name;
1187}
1188"#;
1189        let new = r#"
1190function greet(name: string): string {
1191  return `Hello, ${name}!`;
1192}
1193"#;
1194        let changes = diff_functions_in_file(old, new, Path::new("src/greet.ts")).unwrap();
1195        assert_eq!(changes.len(), 1);
1196        assert_eq!(changes[0].name, "greet");
1197        assert!(changes[0]
1198            .old_body
1199            .as_deref()
1200            .unwrap()
1201            .contains("\"Hello, \""));
1202        assert!(changes[0].new_body.as_deref().unwrap().contains("${name}"));
1203    }
1204
1205    #[test]
1206    fn diff_ignores_comment_only_changes() {
1207        let old = r#"
1208function greet(name: string): string {
1209  // Original comment
1210  return "Hello, " + name;
1211}
1212"#;
1213        let new = r#"
1214function greet(name: string): string {
1215  // Updated comment
1216  return "Hello, " + name;
1217}
1218"#;
1219        let changes = diff_functions_in_file(old, new, Path::new("src/greet.ts")).unwrap();
1220        assert_eq!(changes.len(), 0, "Comment-only changes should be filtered");
1221    }
1222
1223    #[test]
1224    fn diff_ignores_whitespace_only_changes() {
1225        let old = r#"
1226function greet(name: string): string {
1227    return "Hello, " + name;
1228}
1229"#;
1230        let new = r#"
1231function greet(name: string): string {
1232  return "Hello, " + name;
1233}
1234"#;
1235        let changes = diff_functions_in_file(old, new, Path::new("src/greet.ts")).unwrap();
1236        assert_eq!(
1237            changes.len(),
1238            0,
1239            "Whitespace-only changes should be filtered"
1240        );
1241    }
1242
1243    #[test]
1244    fn diff_detects_added_function() {
1245        let old = r#"
1246function existing(): void {
1247  console.log("hello");
1248}
1249"#;
1250        let new = r#"
1251function existing(): void {
1252  console.log("hello");
1253}
1254
1255function added(): void {
1256  console.log("new");
1257}
1258"#;
1259        let changes = diff_functions_in_file(old, new, Path::new("src/funcs.ts")).unwrap();
1260        assert_eq!(changes.len(), 1);
1261        assert_eq!(changes[0].name, "added");
1262        assert!(changes[0].old_body.is_none());
1263        assert!(changes[0].new_body.is_some());
1264    }
1265
1266    #[test]
1267    fn diff_detects_removed_function() {
1268        let old = r#"
1269function removed(): void {
1270  console.log("gone");
1271}
1272
1273function kept(): void {
1274  console.log("still here");
1275}
1276"#;
1277        let new = r#"
1278function kept(): void {
1279  console.log("still here");
1280}
1281"#;
1282        let changes = diff_functions_in_file(old, new, Path::new("src/funcs.ts")).unwrap();
1283        assert_eq!(changes.len(), 1);
1284        assert_eq!(changes[0].name, "removed");
1285        assert!(changes[0].old_body.is_some());
1286        assert!(changes[0].new_body.is_none());
1287    }
1288
1289    #[test]
1290    fn diff_detects_signature_and_body_change() {
1291        let old = r#"
1292function process(input: string): string {
1293  return input.trim();
1294}
1295"#;
1296        let new = r#"
1297function process(input: string, options?: Options): string {
1298  if (options?.validate) input = validate(input);
1299  return input.trim();
1300}
1301"#;
1302        let changes = diff_functions_in_file(old, new, Path::new("src/process.ts")).unwrap();
1303        assert_eq!(changes.len(), 1);
1304        assert!(changes[0]
1305            .old_signature
1306            .as_deref()
1307            .unwrap()
1308            .contains("input: string)"));
1309        assert!(changes[0]
1310            .new_signature
1311            .as_deref()
1312            .unwrap()
1313            .contains("options?: Options"));
1314    }
1315
1316    // ── line_number tests ───────────────────────────────────────────
1317
1318    #[test]
1319    fn line_number_calculation() {
1320        let source = "line1\nline2\nline3\n";
1321        assert_eq!(line_number(source, 0), 1);
1322        assert_eq!(line_number(source, 6), 2); // Start of "line2"
1323        assert_eq!(line_number(source, 12), 3); // Start of "line3"
1324    }
1325
1326    // ── property_key_name tests ─────────────────────────────────────
1327
1328    #[test]
1329    fn extract_react_component() {
1330        let source = r#"
1331export const Button: React.FC<ButtonProps> = ({ children, onClick }) => {
1332  return <button onClick={onClick}>{children}</button>;
1333};
1334"#;
1335        let fns = extract_functions_from_source(source, Path::new("src/Button.tsx")).unwrap();
1336        assert_eq!(fns.len(), 1);
1337        assert_eq!(fns[0].name, "Button");
1338        assert_eq!(fns[0].visibility, Visibility::Exported);
1339    }
1340
1341    // ── forwardRef / memo HOC wrapper extraction ────────────────────
1342
1343    #[test]
1344    fn extract_forward_ref_arrow() {
1345        let source = r#"
1346export const Button = React.forwardRef((props: ButtonProps, ref: React.Ref<any>) => (
1347  <button ref={ref} {...props} />
1348));
1349"#;
1350        let fns = extract_functions_from_source(source, Path::new("src/Button.tsx")).unwrap();
1351        assert_eq!(
1352            fns.len(),
1353            1,
1354            "Should extract arrow inside forwardRef, got: {:?}",
1355            fns.iter().map(|f| &f.name).collect::<Vec<_>>()
1356        );
1357        assert_eq!(fns[0].name, "Button");
1358        assert_eq!(fns[0].visibility, Visibility::Exported);
1359        assert!(
1360            fns[0].body.contains("button"),
1361            "Body should contain the JSX"
1362        );
1363    }
1364
1365    #[test]
1366    fn extract_forward_ref_function_expr() {
1367        let source = r#"
1368export const Input = React.forwardRef(function Input(props: InputProps, ref) {
1369  return <input ref={ref} {...props} />;
1370});
1371"#;
1372        let fns = extract_functions_from_source(source, Path::new("src/Input.tsx")).unwrap();
1373        assert!(
1374            fns.iter()
1375                .any(|f| f.name == "Input" && f.visibility == Visibility::Exported),
1376            "Should extract function inside forwardRef, got: {:?}",
1377            fns.iter()
1378                .map(|f| (&f.name, &f.visibility))
1379                .collect::<Vec<_>>()
1380        );
1381    }
1382
1383    #[test]
1384    fn extract_memo_arrow() {
1385        let source = r#"
1386export const Label = React.memo((props: LabelProps) => {
1387  return <span className="label">{props.text}</span>;
1388});
1389"#;
1390        let fns = extract_functions_from_source(source, Path::new("src/Label.tsx")).unwrap();
1391        assert_eq!(fns.len(), 1);
1392        assert_eq!(fns[0].name, "Label");
1393        assert_eq!(fns[0].visibility, Visibility::Exported);
1394    }
1395
1396    #[test]
1397    fn forward_ref_body_change_detected() {
1398        let old = r#"
1399export const Button = React.forwardRef((props: ButtonProps, ref: React.Ref<any>) => (
1400  <button ref={ref} className="old" {...props} />
1401));
1402"#;
1403        let new = r#"
1404export const Button = React.forwardRef((props: ButtonProps, ref: React.Ref<any>) => (
1405  <button ref={ref} className="new" {...props} />
1406));
1407"#;
1408        let changes = diff_functions_in_file(old, new, Path::new("src/Button.tsx")).unwrap();
1409        assert_eq!(
1410            changes.len(),
1411            1,
1412            "Should detect body change inside forwardRef"
1413        );
1414        assert_eq!(changes[0].name, "Button");
1415    }
1416
1417    #[test]
1418    fn forward_ref_delegates_to_internal_both_extracted() {
1419        let source = r#"
1420const ButtonBase = ({ children, onClick }: ButtonProps) => {
1421  return <button onClick={onClick}>{children}</button>;
1422};
1423
1424export const Button = React.forwardRef((props: ButtonProps, ref: React.Ref<any>) => (
1425  <ButtonBase innerRef={ref} {...props} />
1426));
1427"#;
1428        let fns = extract_functions_from_source(source, Path::new("src/Button.tsx")).unwrap();
1429        let names: Vec<_> = fns.iter().map(|f| f.name.as_str()).collect();
1430        assert!(
1431            names.contains(&"ButtonBase"),
1432            "Should find ButtonBase, got: {:?}",
1433            names
1434        );
1435        assert!(
1436            names.contains(&"Button"),
1437            "Should find Button (forwardRef wrapper), got: {:?}",
1438            names
1439        );
1440    }
1441}