Skip to main content

similarity_core/
function_extractor.rs

1use oxc_ast::ast::*;
2use oxc_span::Span;
3
4use crate::ignore_directive::has_similarity_ignore_directive;
5use crate::parser::parse_and_convert_to_tree;
6use crate::tsed::{calculate_tsed, TSEDOptions};
7
8type CrossFileSimilarityResult = Vec<(String, SimilarityResult, String)>;
9
10#[derive(Debug, Clone)]
11pub struct SimilarityResult {
12    pub func1: FunctionDefinition,
13    pub func2: FunctionDefinition,
14    pub similarity: f64,
15    pub impact: u32, // Total lines that could be removed
16}
17
18impl SimilarityResult {
19    pub fn new(func1: FunctionDefinition, func2: FunctionDefinition, similarity: f64) -> Self {
20        // Impact is the smaller function's line count (since we'd remove the duplicate)
21        let impact = func1.line_count().min(func2.line_count());
22        SimilarityResult { func1, func2, similarity, impact }
23    }
24}
25
26#[derive(Debug, Clone)]
27pub struct FunctionDefinition {
28    pub name: String,
29    pub function_type: FunctionType,
30    pub parameters: Vec<String>,
31    pub body_span: Span,
32    pub start_line: u32,
33    pub end_line: u32,
34    pub class_name: Option<String>,
35    pub parent_function: Option<String>,
36    pub node_count: Option<u32>,
37    pub has_ignore_directive: bool,
38}
39
40impl FunctionDefinition {
41    pub fn line_count(&self) -> u32 {
42        self.end_line - self.start_line + 1
43    }
44
45    /// Check if this function is a parent or child of another function
46    pub fn is_parent_child_relationship(&self, other: &FunctionDefinition) -> bool {
47        // Check if 'other' is inside 'self' (self is parent of other)
48        let other_inside_self = self.start_line <= other.start_line
49            && self.end_line >= other.end_line
50            && self.body_span.start < other.body_span.start
51            && self.body_span.end > other.body_span.end;
52
53        // Check if 'self' is inside 'other' (other is parent of self)
54        let self_inside_other = other.start_line <= self.start_line
55            && other.end_line >= self.end_line
56            && other.body_span.start < self.body_span.start
57            && other.body_span.end > self.body_span.end;
58
59        other_inside_self || self_inside_other
60    }
61}
62
63#[derive(Debug, Clone, PartialEq)]
64pub enum FunctionType {
65    Function,
66    Method,
67    Arrow,
68    Constructor,
69}
70
71/// Extract all functions from TypeScript/JavaScript code
72pub fn extract_functions(
73    filename: &str,
74    source_text: &str,
75) -> Result<Vec<FunctionDefinition>, String> {
76    use oxc_allocator::Allocator;
77    use oxc_parser::Parser;
78    use oxc_span::SourceType;
79
80    let allocator = Allocator::default();
81    let source_type = SourceType::from_path(filename).unwrap_or(SourceType::tsx());
82    let ret = Parser::new(&allocator, source_text, source_type).parse();
83
84    if !ret.errors.is_empty() {
85        // Create a more readable error message
86        let error_messages: Vec<String> =
87            ret.errors.iter().map(|e| e.message.to_string()).collect();
88        return Err(format!("Parse errors: {}", error_messages.join(", ")));
89    }
90
91    let mut functions = Vec::new();
92    let mut context = ExtractionContext {
93        functions: &mut functions,
94        source_text,
95        class_name: None,
96        parent_function: None,
97    };
98
99    extract_from_program(&ret.program, &mut context);
100    Ok(functions)
101}
102
103struct ExtractionContext<'a> {
104    functions: &'a mut Vec<FunctionDefinition>,
105    source_text: &'a str,
106    class_name: Option<String>,
107    parent_function: Option<String>,
108}
109
110fn extract_from_program(program: &Program, ctx: &mut ExtractionContext) {
111    for stmt in &program.body {
112        extract_from_statement(stmt, ctx);
113    }
114}
115
116fn extract_from_statement(stmt: &Statement, ctx: &mut ExtractionContext) {
117    match stmt {
118        Statement::FunctionDeclaration(func) => {
119            if let Some(name) = &func.id {
120                let func_name = name.name.to_string();
121                let params = extract_parameters(&func.params);
122                let start_line = get_line_number(func.span.start, ctx.source_text);
123                ctx.functions.push(FunctionDefinition {
124                    name: func_name.clone(),
125                    function_type: FunctionType::Function,
126                    parameters: params,
127                    body_span: func.span,
128                    start_line,
129                    end_line: get_line_number(func.span.end, ctx.source_text),
130                    class_name: None,
131                    parent_function: ctx.parent_function.clone(),
132                    node_count: count_function_nodes(func.span, ctx.source_text),
133                    has_ignore_directive: has_similarity_ignore_directive(
134                        ctx.source_text,
135                        start_line as usize,
136                    ),
137                });
138
139                // Extract nested functions within the function body
140                if let Some(body) = &func.body {
141                    let saved_parent = ctx.parent_function.clone();
142                    ctx.parent_function = Some(func_name);
143                    extract_from_function_body(body, ctx);
144                    ctx.parent_function = saved_parent;
145                }
146            }
147        }
148        Statement::ClassDeclaration(class) => {
149            let class_name = class.id.as_ref().map(|id| id.name.to_string());
150            let saved_class_name = ctx.class_name.clone();
151            ctx.class_name = class_name.clone();
152
153            for element in &class.body.body {
154                if let ClassElement::MethodDefinition(method) = element {
155                    let method_name = match &method.key {
156                        PropertyKey::StaticIdentifier(ident) => ident.name.to_string(),
157                        PropertyKey::PrivateIdentifier(ident) => format!("#{}", ident.name),
158                        _ => "anonymous".to_string(),
159                    };
160
161                    let params = extract_parameters(&method.value.params);
162                    let function_type = if method.kind == MethodDefinitionKind::Constructor {
163                        FunctionType::Constructor
164                    } else {
165                        FunctionType::Method
166                    };
167
168                    let method_full_name = if let Some(ref class) = class_name {
169                        format!("{class}.{method_name}")
170                    } else {
171                        method_name.clone()
172                    };
173                    let start_line = get_line_number(method.span.start, ctx.source_text);
174
175                    ctx.functions.push(FunctionDefinition {
176                        name: method_name.clone(),
177                        function_type,
178                        parameters: params,
179                        body_span: method.span,
180                        start_line,
181                        end_line: get_line_number(method.span.end, ctx.source_text),
182                        class_name: class_name.clone(),
183                        parent_function: ctx.parent_function.clone(),
184                        node_count: count_function_nodes(method.span, ctx.source_text),
185                        has_ignore_directive: has_similarity_ignore_directive(
186                            ctx.source_text,
187                            start_line as usize,
188                        ),
189                    });
190
191                    // Extract nested functions within method body
192                    if let Some(body) = &method.value.body {
193                        let saved_parent = ctx.parent_function.clone();
194                        ctx.parent_function = Some(method_full_name);
195                        extract_from_function_body(body, ctx);
196                        ctx.parent_function = saved_parent;
197                    }
198                }
199            }
200
201            ctx.class_name = saved_class_name;
202        }
203        Statement::VariableDeclaration(var_decl) => {
204            for decl in &var_decl.declarations {
205                if let Some(Expression::ArrowFunctionExpression(arrow)) = &decl.init {
206                    if let BindingPattern::BindingIdentifier(ident) = &decl.id {
207                        let params = extract_parameters(&arrow.params);
208                        let arrow_name = ident.name.to_string();
209                        let start_line = get_line_number(arrow.span.start, ctx.source_text);
210                        ctx.functions.push(FunctionDefinition {
211                            name: arrow_name.clone(),
212                            function_type: FunctionType::Arrow,
213                            parameters: params,
214                            body_span: arrow.span,
215                            start_line,
216                            end_line: get_line_number(arrow.span.end, ctx.source_text),
217                            class_name: None,
218                            parent_function: ctx.parent_function.clone(),
219                            node_count: count_function_nodes(arrow.span, ctx.source_text),
220                            has_ignore_directive: has_similarity_ignore_directive(
221                                ctx.source_text,
222                                start_line as usize,
223                            ),
224                        });
225
226                        // Extract nested functions within arrow function body
227                        if !arrow.expression {
228                            let saved_parent = ctx.parent_function.clone();
229                            ctx.parent_function = Some(arrow_name);
230                            extract_from_function_body(&arrow.body, ctx);
231                            ctx.parent_function = saved_parent;
232                        }
233                    }
234                }
235            }
236        }
237        Statement::ExportNamedDeclaration(export) => {
238            if let Some(decl) = &export.declaration {
239                extract_from_declaration(decl, ctx);
240            }
241        }
242        Statement::ExportDefaultDeclaration(export) => {
243            if let ExportDefaultDeclarationKind::FunctionDeclaration(func) = &export.declaration {
244                let name = func
245                    .id
246                    .as_ref()
247                    .map(|id| id.name.to_string())
248                    .unwrap_or_else(|| "default".to_string());
249                let params = extract_parameters(&func.params);
250                let func_name = name.clone();
251                let start_line = get_line_number(func.span.start, ctx.source_text);
252                ctx.functions.push(FunctionDefinition {
253                    name: func_name.clone(),
254                    function_type: FunctionType::Function,
255                    parameters: params,
256                    body_span: func.span,
257                    start_line,
258                    end_line: get_line_number(func.span.end, ctx.source_text),
259                    class_name: None,
260                    parent_function: ctx.parent_function.clone(),
261                    node_count: count_function_nodes(func.span, ctx.source_text),
262                    has_ignore_directive: has_similarity_ignore_directive(
263                        ctx.source_text,
264                        start_line as usize,
265                    ),
266                });
267
268                // Extract nested functions within the function body
269                if let Some(body) = &func.body {
270                    let saved_parent = ctx.parent_function.clone();
271                    ctx.parent_function = Some(func_name);
272                    extract_from_function_body(body, ctx);
273                    ctx.parent_function = saved_parent;
274                }
275            }
276        }
277        _ => {}
278    }
279}
280
281fn extract_from_declaration(decl: &Declaration, ctx: &mut ExtractionContext) {
282    match decl {
283        Declaration::FunctionDeclaration(func) => {
284            if let Some(name) = &func.id {
285                let func_name = name.name.to_string();
286                let params = extract_parameters(&func.params);
287                let start_line = get_line_number(func.span.start, ctx.source_text);
288                ctx.functions.push(FunctionDefinition {
289                    name: func_name.clone(),
290                    function_type: FunctionType::Function,
291                    parameters: params,
292                    body_span: func.span,
293                    start_line,
294                    end_line: get_line_number(func.span.end, ctx.source_text),
295                    class_name: None,
296                    parent_function: ctx.parent_function.clone(),
297                    node_count: count_function_nodes(func.span, ctx.source_text),
298                    has_ignore_directive: has_similarity_ignore_directive(
299                        ctx.source_text,
300                        start_line as usize,
301                    ),
302                });
303
304                // Extract nested functions within the function body
305                if let Some(body) = &func.body {
306                    let saved_parent = ctx.parent_function.clone();
307                    ctx.parent_function = Some(func_name);
308                    extract_from_function_body(body, ctx);
309                    ctx.parent_function = saved_parent;
310                }
311            }
312        }
313        Declaration::ClassDeclaration(class) => {
314            let class_name = class.id.as_ref().map(|id| id.name.to_string());
315            let saved_class_name = ctx.class_name.clone();
316            ctx.class_name = class_name.clone();
317
318            for element in &class.body.body {
319                if let ClassElement::MethodDefinition(method) = element {
320                    let method_name = match &method.key {
321                        PropertyKey::StaticIdentifier(ident) => ident.name.to_string(),
322                        PropertyKey::PrivateIdentifier(ident) => format!("#{}", ident.name),
323                        _ => "anonymous".to_string(),
324                    };
325
326                    let params = extract_parameters(&method.value.params);
327                    let function_type = if method.kind == MethodDefinitionKind::Constructor {
328                        FunctionType::Constructor
329                    } else {
330                        FunctionType::Method
331                    };
332
333                    let method_full_name = if let Some(ref class) = class_name {
334                        format!("{class}.{method_name}")
335                    } else {
336                        method_name.clone()
337                    };
338                    let start_line = get_line_number(method.span.start, ctx.source_text);
339
340                    ctx.functions.push(FunctionDefinition {
341                        name: method_name.clone(),
342                        function_type,
343                        parameters: params,
344                        body_span: method.span,
345                        start_line,
346                        end_line: get_line_number(method.span.end, ctx.source_text),
347                        class_name: class_name.clone(),
348                        parent_function: ctx.parent_function.clone(),
349                        node_count: count_function_nodes(method.span, ctx.source_text),
350                        has_ignore_directive: has_similarity_ignore_directive(
351                            ctx.source_text,
352                            start_line as usize,
353                        ),
354                    });
355
356                    // Extract nested functions within method body
357                    if let Some(body) = &method.value.body {
358                        let saved_parent = ctx.parent_function.clone();
359                        ctx.parent_function = Some(method_full_name);
360                        extract_from_function_body(body, ctx);
361                        ctx.parent_function = saved_parent;
362                    }
363                }
364            }
365
366            ctx.class_name = saved_class_name;
367        }
368        Declaration::VariableDeclaration(var) => {
369            for decl in &var.declarations {
370                if let Some(Expression::ArrowFunctionExpression(arrow)) = &decl.init {
371                    if let BindingPattern::BindingIdentifier(ident) = &decl.id {
372                        let params = extract_parameters(&arrow.params);
373                        let arrow_name = ident.name.to_string();
374                        let start_line = get_line_number(arrow.span.start, ctx.source_text);
375                        ctx.functions.push(FunctionDefinition {
376                            name: arrow_name.clone(),
377                            function_type: FunctionType::Arrow,
378                            parameters: params,
379                            body_span: arrow.span,
380                            start_line,
381                            end_line: get_line_number(arrow.span.end, ctx.source_text),
382                            class_name: None,
383                            parent_function: ctx.parent_function.clone(),
384                            node_count: count_function_nodes(arrow.span, ctx.source_text),
385                            has_ignore_directive: has_similarity_ignore_directive(
386                                ctx.source_text,
387                                start_line as usize,
388                            ),
389                        });
390
391                        // Extract nested functions within arrow function body
392                        if !arrow.expression {
393                            let saved_parent = ctx.parent_function.clone();
394                            ctx.parent_function = Some(arrow_name);
395                            extract_from_function_body(&arrow.body, ctx);
396                            ctx.parent_function = saved_parent;
397                        }
398                    }
399                }
400            }
401        }
402        _ => {}
403    }
404}
405
406fn extract_parameters(params: &oxc_ast::ast::FormalParameters) -> Vec<String> {
407    params
408        .items
409        .iter()
410        .filter_map(|param| match &param.pattern {
411            BindingPattern::BindingIdentifier(ident) => Some(ident.name.to_string()),
412            _ => None,
413        })
414        .collect()
415}
416
417fn extract_from_function_body(body: &FunctionBody, ctx: &mut ExtractionContext) {
418    for stmt in &body.statements {
419        extract_from_statement(stmt, ctx);
420    }
421}
422
423fn get_line_number(offset: u32, source_text: &str) -> u32 {
424    let mut line = 1;
425    let mut current_offset = 0;
426
427    for ch in source_text.chars() {
428        if current_offset >= offset as usize {
429            break;
430        }
431        if ch == '\n' {
432            line += 1;
433        }
434        current_offset += ch.len_utf8();
435    }
436
437    line
438}
439
440/// Compare similarity between two functions
441pub fn compare_functions(
442    func1: &FunctionDefinition,
443    func2: &FunctionDefinition,
444    source1: &str,
445    source2: &str,
446    options: &TSEDOptions,
447) -> Result<f64, String> {
448    // Extract function body text
449    let body1 = extract_body_text(func1, source1);
450    let body2 = extract_body_text(func2, source2);
451
452    // Parse and compare
453    let tree1 = parse_and_convert_to_tree("func1.ts", &body1)?;
454    let tree2 = parse_and_convert_to_tree("func2.ts", &body2)?;
455
456    let mut similarity = calculate_tsed(&tree1, &tree2, options);
457
458    // Apply size penalty for short functions if enabled
459    if options.size_penalty {
460        let avg_lines = (func1.line_count() + func2.line_count()) as f64 / 2.0;
461        if avg_lines < 10.0 {
462            // Apply penalty: shorter functions get more penalty
463            let penalty = avg_lines / 10.0;
464            similarity *= penalty;
465        }
466    }
467
468    Ok(similarity)
469}
470
471fn extract_body_text(func: &FunctionDefinition, source: &str) -> String {
472    let start = func.body_span.start as usize;
473    let end = func.body_span.end as usize;
474    source[start..end].to_string()
475}
476
477/// Count the number of AST nodes in a function body
478fn count_function_nodes(body_span: Span, source_text: &str) -> Option<u32> {
479    let start = body_span.start as usize;
480    let end = body_span.end as usize;
481    if start >= end || end > source_text.len() {
482        return None;
483    }
484
485    let body_text = &source_text[start..end];
486
487    // For now, try to parse the text as-is
488    // If it fails, try wrapping it in a way that makes it valid TypeScript
489    match parse_and_convert_to_tree("temp.ts", body_text) {
490        Ok(tree) => Some(tree.get_subtree_size() as u32),
491        Err(_) => {
492            // If direct parsing fails, try wrapping in a minimal context
493            // This handles cases like "constructor(private x: number) {}" or method definitions
494            let wrapped = if body_text.starts_with("constructor") {
495                format!("class C {{ {body_text} }}")
496            } else if body_text.contains("(") && body_text.contains(")") && body_text.contains("{")
497            {
498                // Likely a method or function - wrap it appropriately
499                if body_text.trim().starts_with(|c: char| c.is_alphabetic() || c == '_' || c == '#')
500                {
501                    // Method-like syntax
502                    format!("class C {{ {body_text} }}")
503                } else {
504                    // Arrow function or other expression
505                    format!("const x = {body_text}")
506                }
507            } else {
508                // Default fallback
509                body_text.to_string()
510            };
511
512            match parse_and_convert_to_tree("temp.ts", &wrapped) {
513                Ok(tree) => {
514                    // Subtract nodes added by wrapping (approximation)
515                    let base_nodes = if wrapped.starts_with("class C") {
516                        3 // class node + body node + wrapping
517                    } else if wrapped.starts_with("const x") {
518                        2 // const declaration + wrapping
519                    } else {
520                        0
521                    };
522                    Some((tree.get_subtree_size().saturating_sub(base_nodes)) as u32)
523                }
524                Err(_) => {
525                    // If all else fails, make a rough estimate based on the text
526                    // Count common syntax elements as a fallback
527                    let node_count =
528                        body_text.matches(['{', '}', '(', ')', ';']).count() as u32 + 1;
529                    Some(node_count.max(1))
530                }
531            }
532        }
533    }
534}
535
536/// Find similar functions within the same file
537pub fn find_similar_functions_in_file(
538    filename: &str,
539    source_text: &str,
540    threshold: f64,
541    options: &TSEDOptions,
542) -> Result<Vec<SimilarityResult>, String> {
543    let mut functions = extract_functions(filename, source_text)?;
544    functions.retain(|function| !function.has_ignore_directive);
545    let mut similar_pairs = Vec::new();
546
547    // Compare all pairs
548    for i in 0..functions.len() {
549        for j in (i + 1)..functions.len() {
550            // Skip if either function is too short
551            if let Some(min_tokens) = options.min_tokens {
552                // If min_tokens is specified, use token count instead of line count
553                let tokens_i = functions[i].node_count.unwrap_or(0);
554                let tokens_j = functions[j].node_count.unwrap_or(0);
555                if tokens_i < min_tokens || tokens_j < min_tokens {
556                    continue;
557                }
558            } else {
559                // Otherwise use line count
560                if functions[i].line_count() < options.min_lines
561                    || functions[j].line_count() < options.min_lines
562                {
563                    continue;
564                }
565            }
566
567            // Skip if functions have parent-child relationship
568            if functions[i].is_parent_child_relationship(&functions[j]) {
569                continue;
570            }
571
572            let similarity =
573                compare_functions(&functions[i], &functions[j], source_text, source_text, options)?;
574
575            if similarity >= threshold {
576                similar_pairs.push(SimilarityResult::new(
577                    functions[i].clone(),
578                    functions[j].clone(),
579                    similarity,
580                ));
581            }
582        }
583    }
584
585    // Sort by impact (descending), then by similarity (descending)
586    similar_pairs.sort_by(|a, b| {
587        b.impact
588            .cmp(&a.impact)
589            .then(b.similarity.partial_cmp(&a.similarity).unwrap_or(std::cmp::Ordering::Equal))
590    });
591
592    Ok(similar_pairs)
593}
594
595/// Find similar functions across multiple files
596pub fn find_similar_functions_across_files(
597    files: &[(String, String)], // (filename, source_text)
598    threshold: f64,
599    options: &TSEDOptions,
600) -> Result<CrossFileSimilarityResult, String> {
601    let mut all_functions = Vec::new();
602
603    // Extract functions from all files
604    for (filename, source) in files {
605        let mut functions = extract_functions(filename, source)?;
606        functions.retain(|function| !function.has_ignore_directive);
607        for func in functions {
608            all_functions.push((filename.clone(), source.clone(), func));
609        }
610    }
611
612    let mut similar_pairs = Vec::new();
613
614    // Compare all pairs across files
615    for i in 0..all_functions.len() {
616        for j in (i + 1)..all_functions.len() {
617            let (first_file, source1, func1) = &all_functions[i];
618            let (second_file, source2, func2) = &all_functions[j];
619
620            // Skip if same file (already handled by find_similar_functions_in_file)
621            if first_file == second_file {
622                continue;
623            }
624
625            // Skip if either function is too short
626            if let Some(min_tokens) = options.min_tokens {
627                // If min_tokens is specified, use token count instead of line count
628                let tokens1 = func1.node_count.unwrap_or(0);
629                let tokens2 = func2.node_count.unwrap_or(0);
630                if tokens1 < min_tokens || tokens2 < min_tokens {
631                    continue;
632                }
633            } else {
634                // Otherwise use line count
635                if func1.line_count() < options.min_lines || func2.line_count() < options.min_lines
636                {
637                    continue;
638                }
639            }
640
641            // Skip if functions have parent-child relationship (across files)
642            if func1.is_parent_child_relationship(func2) {
643                continue;
644            }
645
646            let similarity = compare_functions(func1, func2, source1, source2, options)?;
647
648            if similarity >= threshold {
649                similar_pairs.push((
650                    first_file.clone(),
651                    SimilarityResult::new(func1.clone(), func2.clone(), similarity),
652                    second_file.clone(),
653                ));
654            }
655        }
656    }
657
658    // Sort by impact (descending), then by similarity (descending)
659    similar_pairs.sort_by(|a, b| {
660        b.1.impact
661            .cmp(&a.1.impact)
662            .then(b.1.similarity.partial_cmp(&a.1.similarity).unwrap_or(std::cmp::Ordering::Equal))
663    });
664
665    Ok(similar_pairs)
666}
667
668#[cfg(test)]
669mod tests {
670    use super::*;
671
672    #[test]
673    fn test_extract_functions() {
674        let code = r"
675            function add(a: number, b: number): number {
676                return a + b;
677            }
678            
679            const multiply = (x: number, y: number) => x * y;
680            
681            class Calculator {
682                constructor(private initial: number) {}
683                
684                add(value: number): number {
685                    return this.initial + value;
686                }
687                
688                subtract(value: number): number {
689                    return this.initial - value;
690                }
691            }
692            
693            export function divide(a: number, b: number): number {
694                return a / b;
695            }
696        ";
697
698        let functions = extract_functions("test.ts", code).unwrap();
699
700        assert_eq!(functions.len(), 6);
701
702        // Check function names
703        let names: Vec<&str> = functions.iter().map(|f| f.name.as_str()).collect();
704        assert!(names.contains(&"add"));
705        assert!(names.contains(&"multiply"));
706        assert!(names.contains(&"constructor"));
707        assert!(names.contains(&"subtract"));
708        assert!(names.contains(&"divide"));
709
710        // Check function types
711        let add_func =
712            functions.iter().find(|f| f.name == "add" && f.class_name.is_none()).unwrap();
713        assert_eq!(add_func.function_type, FunctionType::Function);
714        assert_eq!(add_func.parameters, vec!["a", "b"]);
715
716        let multiply_func = functions.iter().find(|f| f.name == "multiply").unwrap();
717        assert_eq!(multiply_func.function_type, FunctionType::Arrow);
718
719        let constructor = functions.iter().find(|f| f.name == "constructor").unwrap();
720        assert_eq!(constructor.function_type, FunctionType::Constructor);
721        assert_eq!(constructor.class_name, Some("Calculator".to_string()));
722
723        // Check that node_count is populated for all functions
724        for func in &functions {
725            assert!(
726                func.node_count.is_some(),
727                "Function {} should have node_count populated",
728                func.name
729            );
730            // Node count should be reasonable (greater than 0)
731            assert!(
732                func.node_count.unwrap() > 0,
733                "Function {} should have positive node_count",
734                func.name
735            );
736        }
737    }
738
739    #[test]
740    fn test_node_count_calculation() {
741        let code = r#"
742            function simple() {
743                return 42;
744            }
745            
746            function complex(a: number, b: number): number {
747                if (a > b) {
748                    return a - b;
749                } else {
750                    return a + b;
751                }
752            }
753        "#;
754
755        let functions = extract_functions("test.ts", code).unwrap();
756
757        let simple = functions.iter().find(|f| f.name == "simple").unwrap();
758        let complex = functions.iter().find(|f| f.name == "complex").unwrap();
759
760        println!("Simple function node count: {:?}", simple.node_count);
761        println!("Complex function node count: {:?}", complex.node_count);
762
763        // Simple function should have fewer nodes than complex
764        assert!(simple.node_count.is_some());
765        assert!(complex.node_count.is_some());
766        assert!(simple.node_count.unwrap() < complex.node_count.unwrap());
767    }
768
769    #[test]
770    fn test_find_similar_functions_in_file() {
771        let code = r"
772            function calculateSum(a: number, b: number): number {
773                return a + b;
774            }
775            
776            function addNumbers(x: number, y: number): number {
777                return x + y;
778            }
779            
780            function multiply(a: number, b: number): number {
781                return a * b;
782            }
783            
784            function computeSum(first: number, second: number): number {
785                return first + second;
786            }
787        ";
788
789        let mut options = TSEDOptions::default();
790        options.apted_options.rename_cost = 0.3; // Lower rename cost for better similarity detection
791        options.size_penalty = false; // Disable for test with small functions
792        options.min_lines = 1; // Allow small functions in test
793
794        let similar_pairs = find_similar_functions_in_file("test.ts", code, 0.7, &options).unwrap();
795
796        // Should find that calculateSum, addNumbers, and computeSum are similar
797        assert!(
798            similar_pairs.len() >= 2,
799            "Expected at least 2 similar pairs, found {}",
800            similar_pairs.len()
801        );
802
803        // Note: multiply IS similar to others because they all have the same structure
804        // (two parameters, single return statement). This is expected behavior.
805        // Let's check that we found the expected similar pairs
806        let sum_pairs = similar_pairs
807            .iter()
808            .filter(|result| {
809                (result.func1.name.contains("Sum") || result.func2.name.contains("Sum"))
810                    || (result.func1.name == "addNumbers" || result.func2.name == "addNumbers")
811            })
812            .count();
813        assert!(sum_pairs >= 3, "Expected at least 3 pairs involving sum functions");
814    }
815
816    #[test]
817    fn test_find_similar_functions_across_files() {
818        let file1 = (
819            "file1.ts".to_string(),
820            r#"
821            export function processUser(user: User): void {
822                validateUser(user);
823                saveUser(user);
824                notifyUser(user);
825            }
826            
827            function validateUser(user: User): boolean {
828                return user.name.length > 0 && user.email.includes('@');
829            }
830        "#
831            .to_string(),
832        );
833
834        let file2 = (
835            "file2.ts".to_string(),
836            r#"
837            export function handleUser(u: User): void {
838                checkUser(u);
839                storeUser(u);
840                alertUser(u);
841            }
842            
843            function checkUser(u: User): boolean {
844                return u.name.length > 0 && u.email.includes('@');
845            }
846        "#
847            .to_string(),
848        );
849
850        let mut options = TSEDOptions::default();
851        options.apted_options.rename_cost = 0.3;
852        options.size_penalty = false; // Disable for test with small functions
853        options.min_lines = 1; // Allow small functions in test
854
855        let similar_pairs =
856            find_similar_functions_across_files(&[file1, file2], 0.7, &options).unwrap();
857
858        // Should find that processUser/handleUser and validateUser/checkUser are similar
859        assert!(similar_pairs.len() >= 2);
860
861        // Check specific pairs
862        let process_handle = similar_pairs.iter().find(|(_, result, _)| {
863            (result.func1.name == "processUser" && result.func2.name == "handleUser")
864                || (result.func1.name == "handleUser" && result.func2.name == "processUser")
865        });
866        assert!(process_handle.is_some());
867
868        let validate_check = similar_pairs.iter().find(|(_, result, _)| {
869            (result.func1.name == "validateUser" && result.func2.name == "checkUser")
870                || (result.func1.name == "checkUser" && result.func2.name == "validateUser")
871        });
872        assert!(validate_check.is_some());
873    }
874
875    #[test]
876    fn test_extract_functions_marks_similarity_ignore_directives() {
877        let code = r#"
878function keepMe() {
879    return 1;
880}
881
882// similarity-ignore
883function ignoreMe() {
884    return 2;
885}
886
887/**
888 * Keep this duplicated export local for now.
889 */
890// similarity-ignore
891export function ignoredExport() {
892    return 3;
893}
894"#;
895
896        let functions = extract_functions("test.ts", code).unwrap();
897
898        let keep = functions.iter().find(|f| f.name == "keepMe").unwrap();
899        assert!(!keep.has_ignore_directive);
900
901        let ignored = functions.iter().find(|f| f.name == "ignoreMe").unwrap();
902        assert!(ignored.has_ignore_directive);
903
904        let ignored_export = functions.iter().find(|f| f.name == "ignoredExport").unwrap();
905        assert!(ignored_export.has_ignore_directive);
906    }
907}