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