@@ -29,10 +29,21 @@ internal class ClassBase : ManagedType, IDeserializationCallback
2929 internal readonly Dictionary < int , MethodObject > richcompare = new ( ) ;
3030 internal MaybeType type ;
3131
32+ // How a member is used from Python, so a missing-attribute hint only suggests members
33+ // usable the same way as the one the user most likely meant. Nested types count as
34+ // callable: `Foo.Bar()` may be an attempted constructor call. A single exposed name can
35+ // carry both flags when e.g. a method and a property collapse to the same snake_case name.
36+ [ Flags ]
37+ private enum SuggestionKind
38+ {
39+ Callable = 1 ,
40+ Data = 2 ,
41+ }
42+
3243 // Reflecting over a managed type's full member set (with FlattenHierarchy) plus the
3344 // snake_case conversion is expensive, and the result never changes for a given type.
3445 // Compute it once per type.
35- private static readonly ConcurrentDictionary < Type , HashSet < string > > _candidateNameCache = new ( ) ;
46+ private static readonly ConcurrentDictionary < Type , Dictionary < string , SuggestionKind > > _candidateNameCache = new ( ) ;
3647
3748 // A miss-heavy workload probes the same missing names over and over (e.g. a per-bar
3849 // getattr(self, "_optional", None) on a .NET-derived object, or a mistyped enum value).
@@ -760,8 +771,8 @@ private static string GetSuggestionHint(Type type, string name)
760771
761772 // The hint is built and cached once per (type, name); on a repeated miss this is just
762773 // a dictionary lookup. An empty string means there was nothing to suggest. The
763- // suggested names use the same snake_case convention Python exposes members under
764- // (see ToSnakeCaseMemberName ), so they are independent of whether the access was on
774+ // suggested names use the same convention Python exposes members under (see
775+ // GetCandidateMemberNames ), so they are independent of whether the access was on
765776 // an instance or the type object.
766777 return _suggestionCache . GetOrAdd ( ( type , name ) ,
767778 static key => ComputeSimilarMemberNames ( key . Type , key . Name ) ) ;
@@ -786,17 +797,18 @@ private static string GetErrorMessage(BorrowedReference value, string fallbackNa
786797 return $ "object has no attribute '{ fallbackName } '";
787798 }
788799
789- // The snake_case candidate member names of a type, cached so the reflection and name
790- // conversion happen at most once per type rather than on every attribute miss. Instance
791- // and static members are both included, and each is converted with ToSnakeCaseMemberName
792- // so the suggestion matches the name Python exposes it under: methods become lower_snake,
793- // while enum values, consts and static-readonly members become UPPER_SNAKE (e.g.
794- // DayOfWeek.SUNDAY, Math.PI, String.EMPTY).
795- private static HashSet < string > GetCandidateMemberNames ( Type type )
800+ // The candidate member names of a type, cached so the reflection and name conversion
801+ // happen at most once per type rather than on every attribute miss. Instance and static
802+ // members are both included, and each is converted with ToSnakeCaseMemberName so the
803+ // suggestion matches the name Python exposes it under: methods become lower_snake, enum
804+ // values, consts and static-readonly members become UPPER_SNAKE (e.g. DayOfWeek.SUNDAY,
805+ // Math.PI, String.EMPTY), and nested types keep their original name. Each name is tagged
806+ // with how it is used from Python so suggestions can be filtered by usage.
807+ private static Dictionary < string , SuggestionKind > GetCandidateMemberNames ( Type type )
796808 {
797809 return _candidateNameCache . GetOrAdd ( type , static t =>
798810 {
799- var names = new HashSet < string > ( StringComparer . Ordinal ) ;
811+ var names = new Dictionary < string , SuggestionKind > ( StringComparer . Ordinal ) ;
800812
801813 var members = t . GetMembers ( BindingFlags . Public | BindingFlags . Instance
802814 | BindingFlags . Static | BindingFlags . FlattenHierarchy ) ;
@@ -814,7 +826,8 @@ private static HashSet<string> GetCandidateMemberNames(Type type)
814826 continue ;
815827 }
816828
817- names . Add ( ToSnakeCaseMemberName ( member ) ) ;
829+ var ( name , kind ) = ToSnakeCaseMemberName ( member ) ;
830+ names [ name ] = names . TryGetValue ( name , out var existing ) ? existing | kind : kind ;
818831 }
819832
820833 return names ;
@@ -829,16 +842,16 @@ private static string ComputeSimilarMemberNames(Type type, string name)
829842 const int MaxSuggestions = 5 ;
830843 var threshold = Math . Max ( 2 , name . Length / 3 ) ;
831844
832- var scored = new List < ( string Name , int Distance ) > ( ) ;
845+ var scored = new List < ( string Name , int Distance , SuggestionKind Kind ) > ( ) ;
833846 foreach ( var candidate in GetCandidateMemberNames ( type ) )
834847 {
835- var distance = LevenshteinDistance ( name , candidate ) ;
848+ var distance = LevenshteinDistance ( name , candidate . Key ) ;
836849 var related = distance <= threshold
837- || candidate . IndexOf ( name , StringComparison . OrdinalIgnoreCase ) >= 0
838- || name . IndexOf ( candidate , StringComparison . OrdinalIgnoreCase ) >= 0 ;
850+ || candidate . Key . IndexOf ( name , StringComparison . OrdinalIgnoreCase ) >= 0
851+ || name . IndexOf ( candidate . Key , StringComparison . OrdinalIgnoreCase ) >= 0 ;
839852 if ( related )
840853 {
841- scored . Add ( ( candidate , distance ) ) ;
854+ scored . Add ( ( candidate . Key , distance , candidate . Value ) ) ;
842855 }
843856 }
844857
@@ -847,24 +860,38 @@ private static string ComputeSimilarMemberNames(Type type, string name)
847860 return string . Empty ;
848861 }
849862
850- var suggestions = scored
863+ var ordered = scored
851864 . OrderBy ( t => t . Distance )
852865 . ThenBy ( t => t . Name , StringComparer . OrdinalIgnoreCase )
866+ . ToList ( ) ;
867+
868+ // Only suggest members used the same way as the closest match, the member the user
869+ // most likely meant: a miss that best matches a method or nested type gets callable
870+ // suggestions only, one that best matches a field/property gets data suggestions
871+ // only. Mixing the two would suggest names the caller cannot use the same way.
872+ var kind = ordered [ 0 ] . Kind ;
873+ var suggestions = ordered
874+ . Where ( t => ( t . Kind & kind ) != 0 )
853875 . Take ( MaxSuggestions )
854876 . Select ( t => $ "'{ t . Name } '") ;
855877
856878 return " Did you mean: " + string . Join ( ", " , suggestions ) + "?" ;
857879 }
858880
859- private static string ToSnakeCaseMemberName ( MemberInfo member )
881+ // Converts a member to the name Python exposes it under, tagged with how it is used.
882+ // The field/property overloads of ToSnakeCase are used so const and static-readonly
883+ // members are converted to UPPER_CASE. Nested types keep their original name verbatim:
884+ // ClassManager registers no snake_case alias for them, and they count as callable since
885+ // accessing one may be an attempted constructor call.
886+ private static ( string Name , SuggestionKind Kind ) ToSnakeCaseMemberName ( MemberInfo member )
860887 {
861- // Use the field/property overloads so const and static-readonly members
862- // are converted to UPPER_CASE, matching how they are exposed to Python.
863888 return member switch
864889 {
865- FieldInfo fieldInfo => fieldInfo . ToSnakeCase ( ) ,
866- PropertyInfo propertyInfo => propertyInfo . ToSnakeCase ( ) ,
867- _ => member . Name . ToSnakeCase ( ) ,
890+ Type => ( member . Name , SuggestionKind . Callable ) ,
891+ MethodBase => ( member . Name . ToSnakeCase ( ) , SuggestionKind . Callable ) ,
892+ FieldInfo fieldInfo => ( fieldInfo . ToSnakeCase ( ) , SuggestionKind . Data ) ,
893+ PropertyInfo propertyInfo => ( propertyInfo . ToSnakeCase ( ) , SuggestionKind . Data ) ,
894+ _ => ( member . Name . ToSnakeCase ( ) , SuggestionKind . Data ) ,
868895 } ;
869896 }
870897
0 commit comments