1use 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#[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 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 if !is_source_file(file_path) {
53 continue;
54 }
55
56 match status {
57 FileChange::Added => {
58 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
127enum FileChange {
128 Added,
129 Modified,
130 Deleted,
131 Renamed,
132}
133
134fn 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", &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 results.push((
181 FileChange::Renamed,
182 PathBuf::from(parts[2]),
183 Some(PathBuf::from(parts[1])),
184 ));
185 }
186 _ => {
187 }
189 }
190 }
191
192 Ok(results)
193}
194
195fn 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
212fn is_source_file(path: &Path) -> bool {
221 let path_str = path.to_string_lossy();
222
223 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 if path_str.ends_with(".d.ts") || path_str.ends_with(".d.mts") {
237 return false;
238 }
239
240 if path_str.contains("/dist/") || path_str.starts_with("dist/") {
243 return false;
244 }
245
246 if is_test_file(path) {
248 return false;
249 }
250
251 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
278pub(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#[derive(Debug, Clone)]
295struct ExtractedFunction {
296 qualified_name: String,
298
299 name: String,
301
302 line: usize,
304
305 kind: SymbolKind,
307
308 visibility: Visibility,
310
311 body: String,
313
314 signature: String,
316}
317
318fn 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 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, false, &mut functions,
346 );
347
348 Ok(functions)
349}
350
351fn 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 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 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 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 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 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
480fn 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; }
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 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
561struct FuncExprInfo {
563 kind: SymbolKind,
564 sig: String,
565 body: String,
566}
567
568fn 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 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 Expression::TSAsExpression(ts_as) => extract_from_expression(&ts_as.expression, source),
598
599 Expression::TSSatisfiesExpression(ts_sat) => {
601 extract_from_expression(&ts_sat.expression, source)
602 }
603
604 Expression::ParenthesizedExpression(paren) => {
606 extract_from_expression(&paren.expression, source)
607 }
608
609 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
643fn 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 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 let full = source[func.span.start as usize..func.span.end as usize].to_string();
662 (full, String::new())
663 }
664 }
665}
666
667fn 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 for (qname, old_fn) in &old_map {
697 if let Some(new_fn) = new_map.get(qname) {
698 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 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 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
754fn 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 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 if trimmed.starts_with("//") {
784 continue;
785 }
786
787 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 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
820fn 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
831fn 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
840fn 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 #[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 #[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 #[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 #[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); 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 #[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 #[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); assert_eq!(line_number(source, 12), 3); }
1325
1326 #[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 #[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}