diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index ef49de2d5..6deec74f8 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1052,6 +1052,14 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a } value.Append(' ').Append(overloads); } + + // After the overloads block: consumers that extract the hint from + // its marker onwards must keep this line too. + var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw); + if (mismatch.Length > 0) + { + value.Append('\n').Append(mismatch); + } } catch { @@ -1061,14 +1069,6 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a Exceptions.Clear(); } - // After the overloads block: consumers that extract the hint from - // its marker onwards must keep this line too. - var mismatch = DiagnoseClosestOverloadMismatch(candidates, args, kw); - if (mismatch.Length > 0) - { - value.Append('\n').Append(mismatch); - } - Exceptions.RaiseTypeError(value.ToString()); } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index aa32662d8..ed1659789 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -49,7 +49,7 @@ private enum SuggestionKind // getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value). // Memoize the fully-built " Did you mean: ...?" hint (empty when there is nothing to // suggest) per (type, missing-name) so repeats are a dictionary lookup instead of an - // O(members) reflection + Levenshtein scan on every miss. + // O(members) reflection + similarity scan on every miss. private static readonly ConcurrentDictionary<(Type Type, string Name), string> _suggestionCache = new(); internal ClassBase(Type tp) @@ -837,21 +837,31 @@ private static Dictionary GetCandidateMemberNames(Type t // Builds the " Did you mean: 'x', 'y'?" hint for a missing attribute, or an empty // string when no member is similar enough to suggest. The result is cached in // _suggestionCache, so this runs at most once per (type, missing-name). + // + // Jaro-Winkler (prefix-favoring) keeps suffix-extended targets that an edit-distance + // cutoff rejects (InteractiveBrokers -> INTERACTIVE_BROKERS_BROKERAGE); gated + // containment covers fragment lookups outside its match window ('cash' -> 'set_cash'). private static string ComputeSimilarMemberNames(Type type, string name) { const int MaxSuggestions = 5; - var threshold = Math.Max(2, name.Length / 3); + // In evaluation over real member sets, intended targets scored >= 0.90 and noise <= 0.85. + const double SimilarityThreshold = 0.87; - var scored = new List<(string Name, int Distance, SuggestionKind Kind)>(); + var scored = new List<(string Name, double Score, SuggestionKind Kind)>(); foreach (var candidate in GetCandidateMemberNames(type)) { - var distance = Util.LevenshteinDistance(name, candidate.Key); - var related = distance <= threshold - || candidate.Key.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0 - || name.IndexOf(candidate.Key, StringComparison.OrdinalIgnoreCase) >= 0; - if (related) + var score = Util.JaroWinklerSimilarity(name, candidate.Key); + if (score < SimilarityThreshold) { - scored.Add((candidate.Key, distance, candidate.Value)); + // Coverage scoring ranks containment matches below any similarity match. + score = IsMeaningfulContainment(name, candidate.Key) + ? (double)Math.Min(name.Length, candidate.Key.Length) / Math.Max(name.Length, candidate.Key.Length) + : 0; + } + + if (score > 0) + { + scored.Add((candidate.Key, score, candidate.Value)); } } @@ -861,7 +871,7 @@ private static string ComputeSimilarMemberNames(Type type, string name) } var ordered = scored - .OrderBy(t => t.Distance) + .OrderByDescending(t => t.Score) .ThenBy(t => t.Name, StringComparer.OrdinalIgnoreCase) .ToList(); @@ -895,5 +905,22 @@ private static (string Name, SuggestionKind Kind) ToSnakeCaseMemberName(MemberIn }; } + // Without the length gates every 1-2 letter member is a substring of any long + // missed name and floods the suggestion list. + private static bool IsMeaningfulContainment(string name, string candidate) + { + const int MinFragmentLength = 3; + + if (name.Length >= MinFragmentLength + && candidate.IndexOf(name, StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + return candidate.Length >= MinFragmentLength + && 2 * candidate.Length >= name.Length + && name.IndexOf(candidate, StringComparison.OrdinalIgnoreCase) >= 0; + } + } } diff --git a/src/runtime/Util/Util.cs b/src/runtime/Util/Util.cs index 2e17911bf..4f291c14f 100644 --- a/src/runtime/Util/Util.cs +++ b/src/runtime/Util/Util.cs @@ -363,5 +363,91 @@ internal static int LevenshteinDistance(string a, string b) } return prev[m]; } + + /// + /// Case-insensitive Jaro-Winkler similarity in [0, 1] (Jaro boosted by shared prefix). + /// + internal static double JaroWinklerSimilarity(string a, string b) + { + const double PrefixScale = 0.1; + const int MaxPrefixLength = 4; + + a = a.ToLowerInvariant(); + b = b.ToLowerInvariant(); + + var jaro = JaroSimilarity(a, b); + + var prefix = 0; + var maxPrefix = Math.Min(MaxPrefixLength, Math.Min(a.Length, b.Length)); + while (prefix < maxPrefix && a[prefix] == b[prefix]) + { + prefix++; + } + + return jaro + prefix * PrefixScale * (1 - jaro); + } + + private static double JaroSimilarity(string a, string b) + { + if (a == b) + { + return 1; + } + + var n = a.Length; + var m = b.Length; + if (n == 0 || m == 0) + { + return 0; + } + + var window = Math.Max(0, Math.Max(n, m) / 2 - 1); + var aMatched = new bool[n]; + var bMatched = new bool[m]; + + var matches = 0; + for (var i = 0; i < n; i++) + { + var lo = Math.Max(0, i - window); + var hi = Math.Min(m, i + window + 1); + for (var j = lo; j < hi; j++) + { + if (!bMatched[j] && a[i] == b[j]) + { + aMatched[i] = bMatched[j] = true; + matches++; + break; + } + } + } + + if (matches == 0) + { + return 0; + } + + var transpositions = 0; + var k = 0; + for (var i = 0; i < n; i++) + { + if (!aMatched[i]) + { + continue; + } + while (!bMatched[k]) + { + k++; + } + if (a[i] != b[k]) + { + transpositions++; + } + k++; + } + transpositions /= 2; + + return ((double)matches / n + (double)matches / m + + (double)(matches - transpositions) / matches) / 3; + } } } diff --git a/src/testing/classtest.cs b/src/testing/classtest.cs index 0c726e866..e7ae6a996 100644 --- a/src/testing/classtest.cs +++ b/src/testing/classtest.cs @@ -86,5 +86,31 @@ public static int[] CalculationResults() } public static int CalculationResult { get; set; } + + // Short members, all substrings of a longer miss like 'set_account_type', which + // must not suggest them. + public static int T() + { + return 0; + } + + public static int CC() + { + return 0; + } + + public static void SetAccountCurrency(string currency) + { + } + } + + /// + /// Supports suggestion tests for enum members that extend the guessed name with a suffix. + /// + public enum SuggestionEnum + { + InteractiveBrokersBrokerage, + InteractiveBrokersFix, + Binance, } } diff --git a/tests/test_class.py b/tests/test_class.py index df374af92..c5d34ca51 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -211,6 +211,40 @@ def test_missing_property_suggests_data_only(): assert "'calculation_results'" not in hint +def _suggestions(message): + """Extract the quoted member names from a "Did you mean" hint.""" + import re + return re.findall(r"'([^']+)'", message.split("Did you mean")[1]) + + +def test_missing_attribute_does_not_suggest_short_members(): + """A long missed name must not collect 1-2 letter members via substring containment. + + Every short member is a substring of a long miss, so hints used to read + "Did you mean: 'cc', 'co', 'a', 'c', 't'?" while the intended member was absent. + """ + from Python.Test import SuggestionTest + + with pytest.raises(AttributeError) as exc_info: + _ = SuggestionTest.set_account_type + + message = str(exc_info.value) + assert "Did you mean" in message + suggested = _suggestions(message) + assert "set_account_currency" in suggested + assert all(len(s) > 2 for s in suggested) + + +def test_missing_attribute_fragment_suggests_containing_member(): + """Typing a meaningful fragment of a member name still suggests that member.""" + from Python.Test import SuggestionTest + + with pytest.raises(AttributeError) as exc_info: + _ = SuggestionTest.currency + + assert "set_account_currency" in _suggestions(str(exc_info.value)) + + def test_missing_static_member_no_similar(): """A static member with no similar name keeps the standard message (no hint).""" from System import Math diff --git a/tests/test_enum.py b/tests/test_enum.py index 4c15a431e..96a8dfeb2 100644 --- a/tests/test_enum.py +++ b/tests/test_enum.py @@ -68,6 +68,24 @@ def test_missing_enum_member_hasattr_still_false(): assert not hasattr(DayOfWeek, "Sundey") +def test_missing_enum_member_suffix_extended_name_suggested(): + """A guessed name that the real member extends with a suffix must be suggested, + from both the PascalCase and the UPPER_SNAKE spelling of the guess.""" + import re + from Python.Test import SuggestionEnum + + for miss in ("InteractiveBrokers", "INTERACTIVE_BROKERS"): + with pytest.raises(AttributeError) as exc_info: + getattr(SuggestionEnum, miss) + + message = str(exc_info.value) + assert "Did you mean" in message + suggested = re.findall(r"'([^']+)'", message.split("Did you mean")[1]) + assert "INTERACTIVE_BROKERS_BROKERAGE" in suggested + assert "INTERACTIVE_BROKERS_FIX" in suggested + assert "BINANCE" not in suggested + + def test_byte_enum(): """Test byte enum.""" assert Test.ByteEnum.Zero == Test.ByteEnum(0)