1use crate::JpxEngine;
8use jpx_core::registry::{Category, FunctionInfo, expand_search_terms, lookup_synonyms};
9use serde::{Deserialize, Serialize};
10use strsim::jaro_winkler;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct FunctionDetail {
32 pub name: String,
34 pub category: String,
36 pub description: String,
38 pub signature: String,
40 pub example: String,
42 pub is_standard: bool,
44 #[serde(skip_serializing_if = "Option::is_none")]
46 pub jep: Option<String>,
47 #[serde(default, skip_serializing_if = "Vec::is_empty")]
49 pub aliases: Vec<String>,
50}
51
52impl From<&FunctionInfo> for FunctionDetail {
53 fn from(info: &FunctionInfo) -> Self {
54 Self {
55 name: info.name.to_string(),
56 category: format!("{:?}", info.category),
57 description: info.description.to_string(),
58 signature: info.signature.to_string(),
59 example: info.example.to_string(),
60 is_standard: info.is_standard,
61 jep: info.jep.map(|s| s.to_string()),
62 aliases: info.aliases.iter().map(|s| s.to_string()).collect(),
63 }
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct SearchResult {
85 pub function: FunctionDetail,
87 pub match_type: String,
89 pub score: i32,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct SimilarFunctionsResult {
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
120 pub same_category: Vec<FunctionDetail>,
121 #[serde(default, skip_serializing_if = "Vec::is_empty")]
123 pub similar_signature: Vec<FunctionDetail>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub related_concepts: Vec<FunctionDetail>,
127}
128
129impl JpxEngine {
134 pub fn categories(&self) -> Vec<String> {
151 Category::all().iter().map(|c| format!("{:?}", c)).collect()
152 }
153
154 pub fn functions(&self, category: Option<&str>) -> Vec<FunctionDetail> {
176 match category {
177 Some(name) => match parse_category(name) {
178 Some(cat) => self
179 .registry
180 .functions_in_category(cat)
181 .map(FunctionDetail::from)
182 .collect(),
183 None => Vec::new(),
184 },
185 None => self
186 .registry
187 .functions()
188 .map(FunctionDetail::from)
189 .collect(),
190 }
191 }
192
193 pub fn describe_function(&self, name: &str) -> Option<FunctionDetail> {
220 self.registry.get_function(name).map(FunctionDetail::from)
221 }
222
223 pub fn search_functions(&self, query: &str, limit: usize) -> Vec<SearchResult> {
255 let query_lower = query.to_lowercase();
256
257 let expanded_terms = expand_search_terms(&query_lower);
259
260 let all_functions: Vec<_> = self.registry.functions().collect();
261 let mut results: Vec<SearchResult> = Vec::new();
262
263 for info in &all_functions {
264 let name_lower = info.name.to_lowercase();
265 let desc_lower = info.description.to_lowercase();
266 let category_lower = format!("{:?}", info.category).to_lowercase();
267 let signature_lower = info.signature.to_lowercase();
268 let aliases_lower: Vec<String> = info
269 .aliases
270 .iter()
271 .map(|a: &&str| a.to_lowercase())
272 .collect();
273
274 let (score, match_type) = calculate_match_score(
276 &query_lower,
277 &expanded_terms,
278 &MatchContext {
279 name: &name_lower,
280 aliases: &aliases_lower,
281 category: &category_lower,
282 description: &desc_lower,
283 signature: &signature_lower,
284 },
285 );
286
287 if score > 0 {
288 results.push(SearchResult {
289 function: FunctionDetail::from(*info),
290 match_type,
291 score,
292 });
293 }
294 }
295
296 results.sort_by(|a, b| {
298 b.score
299 .cmp(&a.score)
300 .then_with(|| a.function.name.cmp(&b.function.name))
301 });
302
303 results.truncate(limit);
304 results
305 }
306
307 pub fn similar_functions(&self, name: &str) -> Option<SimilarFunctionsResult> {
338 let info = self.registry.get_function(name)?;
339 let all_functions: Vec<_> = self.registry.functions().collect();
340
341 let same_category: Vec<FunctionDetail> = all_functions
343 .iter()
344 .filter(|f| f.category == info.category && f.name != info.name)
345 .take(5)
346 .map(|f| FunctionDetail::from(*f))
347 .collect();
348
349 let this_arity = count_params(info.signature);
351 let similar_signature: Vec<FunctionDetail> = all_functions
352 .iter()
353 .filter(|f| {
354 f.name != info.name
355 && f.category != info.category
356 && count_params(f.signature) == this_arity
357 })
358 .take(5)
359 .map(|f| FunctionDetail::from(*f))
360 .collect();
361
362 let keywords = extract_keywords(info.description);
364 let mut concept_scores: Vec<(&FunctionInfo, usize)> = all_functions
365 .iter()
366 .filter(|f| f.name != info.name)
367 .map(|f| {
368 let f_keywords = extract_keywords(f.description);
369 let overlap = keywords.iter().filter(|k| f_keywords.contains(*k)).count();
370 (*f, overlap)
371 })
372 .filter(|(_, score)| *score > 0)
373 .collect();
374
375 concept_scores.sort_by(|a, b| b.1.cmp(&a.1));
376
377 let related_concepts: Vec<FunctionDetail> = concept_scores
378 .into_iter()
379 .take(5)
380 .map(|(f, _)| FunctionDetail::from(f))
381 .collect();
382
383 Some(SimilarFunctionsResult {
384 same_category,
385 similar_signature,
386 related_concepts,
387 })
388 }
389}
390
391struct MatchContext<'a> {
397 name: &'a str,
398 aliases: &'a [String],
399 category: &'a str,
400 description: &'a str,
401 signature: &'a str,
402}
403
404fn calculate_match_score(
406 query: &str,
407 expanded_terms: &[String],
408 ctx: &MatchContext,
409) -> (i32, String) {
410 if ctx.name == query {
412 return (1000, "exact_name".to_string());
413 }
414
415 if ctx.aliases.iter().any(|a| a == query) {
417 return (900, "alias".to_string());
418 }
419
420 if ctx.name.starts_with(query) {
422 return (800, "name_prefix".to_string());
423 }
424
425 if ctx.name.contains(query) {
427 return (700, "name_contains".to_string());
428 }
429
430 if ctx.category == query {
432 return (600, "category".to_string());
433 }
434
435 let mut desc_score = 0;
437 let mut matched_terms = Vec::new();
438
439 for term in expanded_terms {
440 if ctx.description.contains(term) || ctx.signature.contains(term) {
441 desc_score += 100;
442 matched_terms.push(term.clone());
443 }
444 }
445
446 if desc_score > 0 {
447 return (
448 desc_score,
449 format!("description ({})", matched_terms.join(", ")),
450 );
451 }
452
453 let similarity = jaro_winkler(query, ctx.name);
455 if similarity > 0.8 {
456 return ((similarity * 500.0) as i32, "fuzzy_name".to_string());
457 }
458
459 if let Some(synonyms) = lookup_synonyms(query) {
461 for syn in synonyms {
462 if ctx.name.contains(syn) || ctx.description.contains(syn) {
463 return (300, format!("synonym ({})", syn));
464 }
465 }
466 }
467
468 (0, String::new())
469}
470
471pub(crate) fn parse_category(name: &str) -> Option<Category> {
473 let input = name.to_lowercase();
474 Category::all()
475 .iter()
476 .find(|cat| cat.name() == input)
477 .copied()
478}
479
480fn count_params(signature: &str) -> usize {
485 let input = match signature.find("->") {
487 Some(pos) => &signature[..pos],
488 None => signature,
489 };
490 let input = input.trim();
491 if input.is_empty() {
492 return 0;
493 }
494
495 let mut params = 1;
496 let mut depth = 0;
497 for ch in input.chars() {
498 match ch {
499 '[' | '(' => depth += 1,
500 ']' | ')' => depth -= 1,
501 ',' if depth == 0 => params += 1,
502 _ => {}
503 }
504 }
505 params
506}
507
508fn extract_keywords(description: &str) -> Vec<&str> {
510 let stopwords = [
511 "a",
512 "an",
513 "the",
514 "is",
515 "are",
516 "was",
517 "were",
518 "be",
519 "been",
520 "being",
521 "have",
522 "has",
523 "had",
524 "do",
525 "does",
526 "did",
527 "will",
528 "would",
529 "could",
530 "should",
531 "may",
532 "might",
533 "must",
534 "shall",
535 "can",
536 "to",
537 "of",
538 "in",
539 "for",
540 "on",
541 "with",
542 "at",
543 "by",
544 "from",
545 "or",
546 "and",
547 "as",
548 "if",
549 "that",
550 "which",
551 "this",
552 "these",
553 "those",
554 "it",
555 "its",
556 "such",
557 "when",
558 "where",
559 "how",
560 "all",
561 "each",
562 "every",
563 "both",
564 "few",
565 "more",
566 "most",
567 "other",
568 "some",
569 "any",
570 "no",
571 "not",
572 "only",
573 "same",
574 "than",
575 "very",
576 "just",
577 "also",
578 "into",
579 "over",
580 "after",
581 "before",
582 "between",
583 "under",
584 "again",
585 "further",
586 "then",
587 "once",
588 "here",
589 "there",
590 "why",
591 "because",
592 "while",
593 "although",
594 "though",
595 "unless",
596 "until",
597 "whether",
598 "returns",
599 "return",
600 "value",
601 "values",
602 "given",
603 "input",
604 "output",
605 "function",
606 "functions",
607 "used",
608 "using",
609 "use",
610 ];
611
612 description
613 .split(|c: char| !c.is_alphanumeric())
614 .filter(|w| w.len() > 2 && !stopwords.contains(&w.to_lowercase().as_str()))
615 .collect()
616}
617
618#[cfg(test)]
619mod tests {
620 use crate::JpxEngine;
621
622 #[test]
623 fn test_categories() {
624 let engine = JpxEngine::new();
625 let cats = engine.categories();
626 assert!(!cats.is_empty());
627 assert!(cats.iter().any(|c| c == "String"));
628 }
629
630 #[test]
631 fn test_functions() {
632 let engine = JpxEngine::new();
633
634 let all = engine.functions(None);
636 assert!(!all.is_empty());
637
638 let string_funcs = engine.functions(Some("String"));
640 assert!(!string_funcs.is_empty());
641 assert!(string_funcs.iter().all(|f| f.category == "String"));
642 }
643
644 #[test]
645 fn test_describe_function() {
646 let engine = JpxEngine::new();
647
648 let info = engine.describe_function("upper").unwrap();
649 assert_eq!(info.name, "upper");
650 assert_eq!(info.category, "String");
651
652 let missing = engine.describe_function("nonexistent");
653 assert!(missing.is_none());
654 }
655
656 #[test]
657 fn test_search_functions() {
658 let engine = JpxEngine::new();
659
660 let results = engine.search_functions("string", 10);
661 assert!(!results.is_empty());
662 }
663
664 #[test]
665 fn test_similar_functions() {
666 let engine = JpxEngine::new();
667
668 let result = engine.similar_functions("upper").unwrap();
669 assert!(!result.same_category.is_empty());
671 }
672
673 #[test]
678 fn test_categories_contains_common() {
679 let engine = JpxEngine::new();
680 let cats = engine.categories();
681 for expected in &["Math", "Array", "Object", "Utility"] {
682 assert!(
683 cats.iter().any(|c| c == expected),
684 "Expected categories to contain {:?}",
685 expected
686 );
687 }
688 }
689
690 #[test]
691 fn test_categories_no_duplicates() {
692 let engine = JpxEngine::new();
693 let cats = engine.categories();
694 let mut seen = std::collections::HashSet::new();
695 for cat in &cats {
696 assert!(
697 seen.insert(cat.clone()),
698 "Duplicate category found: {:?}",
699 cat
700 );
701 }
702 }
703
704 #[test]
709 fn test_functions_all() {
710 let engine = JpxEngine::new();
711 let all = engine.functions(None);
712 assert!(
713 !all.is_empty(),
714 "functions(None) should return a non-empty list"
715 );
716 assert!(
718 all.len() > 100,
719 "Expected at least 100 functions, got {}",
720 all.len()
721 );
722 }
723
724 #[test]
725 fn test_functions_invalid_category() {
726 let engine = JpxEngine::new();
727 let invalid = engine.functions(Some("NonexistentCategory"));
728 assert!(
729 invalid.is_empty(),
730 "Invalid category should return empty list, got {} functions",
731 invalid.len()
732 );
733 }
734
735 #[test]
736 fn test_functions_multi_match_category() {
737 let engine = JpxEngine::new();
738 let results = engine.functions(Some("multi-match"));
739 assert!(
740 !results.is_empty(),
741 "multi-match category should return functions"
742 );
743 let all = engine.functions(None);
744 assert!(
745 results.len() < all.len(),
746 "multi-match should return a subset, not all {} functions",
747 all.len()
748 );
749 }
750
751 #[test]
752 fn test_functions_case_insensitive() {
753 let engine = JpxEngine::new();
754 let lower = engine.functions(Some("string"));
755 let upper = engine.functions(Some("String"));
756 assert!(
757 !lower.is_empty(),
758 "functions(Some(\"string\")) should return results"
759 );
760 assert_eq!(
761 lower.len(),
762 upper.len(),
763 "Case-insensitive category lookup should return the same number of results"
764 );
765 let lower_names: Vec<&str> = lower.iter().map(|f| f.name.as_str()).collect();
767 let upper_names: Vec<&str> = upper.iter().map(|f| f.name.as_str()).collect();
768 assert_eq!(lower_names, upper_names);
769 }
770
771 #[test]
776 fn test_describe_function_detail_fields() {
777 let engine = JpxEngine::new();
778 let info = engine.describe_function("length").unwrap();
779 assert_eq!(info.name, "length");
780 assert!(!info.category.is_empty(), "category should not be empty");
781 assert!(info.is_standard, "length should be a standard function");
782 assert!(
783 !info.description.is_empty(),
784 "description should not be empty"
785 );
786 assert!(!info.signature.is_empty(), "signature should not be empty");
787 }
788
789 #[test]
790 fn test_describe_function_extension() {
791 let engine = JpxEngine::new();
792 let info = engine.describe_function("upper").unwrap();
793 assert_eq!(info.name, "upper");
794 assert!(!info.is_standard, "upper should not be a standard function");
795 }
796
797 #[test]
798 fn test_describe_function_by_alias() {
799 let engine = JpxEngine::new();
800 let info = engine.describe_function("all_expr").unwrap();
802 assert!(
803 info.aliases.contains(&"every".to_string()),
804 "all_expr should have alias 'every', aliases: {:?}",
805 info.aliases
806 );
807 let results = engine.search_functions("every", 5);
809 assert!(
810 results.iter().any(|r| r.function.name == "all_expr"),
811 "Searching for alias 'every' should find 'all_expr'"
812 );
813 let alias_result = results
814 .iter()
815 .find(|r| r.function.name == "all_expr")
816 .unwrap();
817 assert_eq!(
818 alias_result.match_type, "alias",
819 "Match type for alias search should be 'alias'"
820 );
821 assert_eq!(alias_result.score, 900, "Alias match score should be 900");
822 }
823
824 #[test]
829 fn test_search_exact_name_match() {
830 let engine = JpxEngine::new();
831 let results = engine.search_functions("upper", 10);
832 assert!(!results.is_empty(), "Should find results for 'upper'");
833 let first = &results[0];
834 assert_eq!(first.function.name, "upper");
835 assert_eq!(first.match_type, "exact_name");
836 assert_eq!(first.score, 1000);
837 }
838
839 #[test]
840 fn test_search_name_prefix_match() {
841 let engine = JpxEngine::new();
842 let results = engine.search_functions("to_", 20);
843 assert!(!results.is_empty(), "Should find results for prefix 'to_'");
844 let prefix_results: Vec<_> = results
846 .iter()
847 .filter(|r| r.match_type == "name_prefix")
848 .collect();
849 assert!(
850 !prefix_results.is_empty(),
851 "Should have at least one name_prefix match"
852 );
853 for r in &prefix_results {
854 assert_eq!(r.score, 800, "name_prefix score should be 800");
855 assert!(
856 r.function.name.starts_with("to_"),
857 "Function '{}' should start with 'to_'",
858 r.function.name
859 );
860 }
861 }
862
863 #[test]
864 fn test_search_name_contains_match() {
865 let engine = JpxEngine::new();
866 let results = engine.search_functions("left", 20);
868 let contains_results: Vec<_> = results
869 .iter()
870 .filter(|r| r.match_type == "name_contains")
871 .collect();
872 assert!(
873 !contains_results.is_empty(),
874 "Should have at least one name_contains match for 'left'"
875 );
876 for r in &contains_results {
877 assert_eq!(r.score, 700, "name_contains score should be 700");
878 assert!(
879 r.function.name.contains("left"),
880 "Function '{}' should contain 'left'",
881 r.function.name
882 );
883 }
884 }
885
886 #[test]
887 fn test_search_description_match() {
888 let engine = JpxEngine::new();
889 let results = engine.search_functions("converts", 10);
891 if !results.is_empty() {
892 let desc_matches: Vec<_> = results
894 .iter()
895 .filter(|r| r.match_type.starts_with("description"))
896 .collect();
897 assert!(
898 !desc_matches.is_empty(),
899 "Matches for 'converts' should include description matches"
900 );
901 for r in &desc_matches {
902 assert!(
903 r.score >= 100,
904 "Description match score should be at least 100"
905 );
906 }
907 }
908 }
909
910 #[test]
911 fn test_search_case_insensitive() {
912 let engine = JpxEngine::new();
913 let upper_results = engine.search_functions("UPPER", 10);
914 let lower_results = engine.search_functions("upper", 10);
915 assert!(
917 upper_results.iter().any(|r| r.function.name == "upper"),
918 "Searching for 'UPPER' should find 'upper'"
919 );
920 assert!(
921 lower_results.iter().any(|r| r.function.name == "upper"),
922 "Searching for 'upper' should find 'upper'"
923 );
924 }
925
926 #[test]
927 fn test_search_respects_limit() {
928 let engine = JpxEngine::new();
929 let results = engine.search_functions("string", 3);
930 assert!(
931 results.len() <= 3,
932 "Results should be limited to 3, got {}",
933 results.len()
934 );
935 }
936
937 #[test]
938 fn test_search_results_ordered_by_score() {
939 let engine = JpxEngine::new();
940 let results = engine.search_functions("string", 20);
941 for window in results.windows(2) {
942 assert!(
943 window[0].score >= window[1].score,
944 "Results should be sorted by score descending: {} (score {}) came before {} (score {})",
945 window[0].function.name,
946 window[0].score,
947 window[1].function.name,
948 window[1].score,
949 );
950 }
951 }
952
953 #[test]
954 fn test_search_empty_query() {
955 let engine = JpxEngine::new();
956 let results = engine.search_functions("", 10);
958 let _ = results;
960 }
961
962 #[test]
963 fn test_search_no_results() {
964 let engine = JpxEngine::new();
965 let results = engine.search_functions("xyzqwerty123", 10);
966 assert!(
967 results.is_empty(),
968 "Should find no results for nonsense query, got {}",
969 results.len()
970 );
971 }
972
973 #[test]
978 fn test_similar_functions_nonexistent() {
979 let engine = JpxEngine::new();
980 let result = engine.similar_functions("nonexistent");
981 assert!(
982 result.is_none(),
983 "similar_functions for nonexistent function should return None"
984 );
985 }
986
987 #[test]
988 fn test_similar_same_category_populated() {
989 let engine = JpxEngine::new();
990 let result = engine.similar_functions("upper").unwrap();
991 assert!(
992 !result.same_category.is_empty(),
993 "same_category should be populated for 'upper'"
994 );
995 for f in &result.same_category {
997 assert_eq!(
998 f.category, "String",
999 "same_category function '{}' should be in String category, got '{}'",
1000 f.name, f.category
1001 );
1002 }
1003 assert!(
1005 !result.same_category.iter().any(|f| f.name == "upper"),
1006 "same_category should not contain the function itself"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_count_params_simple() {
1012 assert_eq!(super::count_params("string -> string"), 1);
1013 assert_eq!(super::count_params("string, string -> string"), 2);
1014 assert_eq!(super::count_params("string, number, string -> string"), 3);
1015 }
1016
1017 #[test]
1018 fn test_count_params_brackets() {
1019 assert_eq!(
1021 super::count_params("array, array[[string, string]] -> array"),
1022 2
1023 );
1024 assert_eq!(
1026 super::count_params("array[object[string, number]] -> array"),
1027 1
1028 );
1029 }
1030
1031 #[test]
1032 fn test_count_params_optional_and_variadic() {
1033 assert_eq!(super::count_params("string, string? -> string"), 2);
1034 assert_eq!(super::count_params("string, ...any -> array"), 2);
1035 }
1036
1037 #[test]
1038 fn test_count_params_empty() {
1039 assert_eq!(super::count_params("-> string"), 0);
1040 }
1041
1042 #[test]
1043 fn test_similar_signature_uses_correct_arity() {
1044 let engine = super::super::JpxEngine::new();
1045 let result = engine.similar_functions("order_by").unwrap();
1047 for f in &result.similar_signature {
1048 let arity = super::count_params(&f.signature);
1049 assert_eq!(
1050 arity, 2,
1051 "similar_signature match '{}' (sig: '{}') should have arity 2, got {}",
1052 f.name, f.signature, arity
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn test_similar_functions_serde() {
1059 let engine = JpxEngine::new();
1060 let result = engine.similar_functions("upper").unwrap();
1061 let json = serde_json::to_string(&result)
1063 .expect("SimilarFunctionsResult should serialize to JSON");
1064 assert!(!json.is_empty(), "Serialized JSON should not be empty");
1065 let deserialized: super::SimilarFunctionsResult = serde_json::from_str(&json)
1067 .expect("SimilarFunctionsResult should deserialize from JSON");
1068 assert_eq!(
1069 result.same_category.len(),
1070 deserialized.same_category.len(),
1071 "Round-trip should preserve same_category length"
1072 );
1073 }
1074}