diff --git a/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts new file mode 100644 index 000000000..d0ea89789 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/accessor-unwrap.ts @@ -0,0 +1,57 @@ +/** + * C# collection-accessor unwrapping. + * + * When the compound-receiver resolver encounters a trailing + * `.Values` / `.Keys` on a dotted member-access chain, it calls the + * provider's `unwrapCollectionAccessor` hook to find the element + * type. This module supplies the C# implementation — recognizing + * Dictionary-family generics and returning the value or key type. + * + * Other languages (Python, Java, TypeScript) use method-call syntax + * for the same access (`.values()` / `.keys()`), which the compound- + * receiver's call-expression branch already handles; they leave this + * hook undefined. + */ + +/** Extract (K, V) from `Dictionary` / `IDictionary` / + * `IReadOnlyDictionary` / `SortedDictionary` / + * `ConcurrentDictionary` / `ImmutableDictionary`. + * Returns undefined if the type name doesn't match or the argument + * list isn't exactly two top-level args. */ +function extractDictionaryArgs(rawName: string): { key: string; value: string } | undefined { + const match = rawName.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Dictionary|IDictionary|IReadOnlyDictionary|SortedDictionary|ConcurrentDictionary|ImmutableDictionary)<(.+)>$/, + ); + if (match === null) return undefined; + const inner = match[1]!; + // Split on the top-level comma (tolerate nested `<...>`). + let depth = 0; + let commaIdx = -1; + for (let i = 0; i < inner.length; i++) { + const ch = inner[i]; + if (ch === '<') depth++; + else if (ch === '>') depth--; + else if (ch === ',' && depth === 0) { + commaIdx = i; + break; + } + } + if (commaIdx === -1) return undefined; + return { key: inner.slice(0, commaIdx).trim(), value: inner.slice(commaIdx + 1).trim() }; +} + +/** + * Resolve `data.Values` / `data.Keys` on a Dictionary-like receiver + * to its element-type simple name. Returns `undefined` for any + * receiver / accessor combination we don't recognize, letting the + * compound-receiver pass fall through to the regular field walk. + */ +export function unwrapCsharpCollectionAccessor( + receiverType: string, + accessor: string, +): string | undefined { + if (accessor !== 'Values' && accessor !== 'Keys') return undefined; + const args = extractDictionaryArgs(receiverType); + if (args === undefined) return undefined; + return accessor === 'Values' ? args.value : args.key; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts index 75d99880a..e34ef2f01 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/scope-resolver.ts @@ -19,6 +19,7 @@ import { type CsharpResolveContext, } from './index.js'; import { populateCsharpNamespaceSiblings } from './namespace-siblings.js'; +import { unwrapCsharpCollectionAccessor } from './accessor-unwrap.js'; const csharpScopeResolver: ScopeResolver = { language: SupportedLanguages.CSharp, @@ -72,6 +73,11 @@ const csharpScopeResolver: ScopeResolver = { fieldFallbackOnMethodLookup: false, propagatesReturnTypesAcrossImports: true, + // `data.Values` / `data.Keys` on Dictionary-like receivers unwrap + // to the value / key element type. Other languages use method-call + // syntax for the same access and leave this hook undefined. + unwrapCollectionAccessor: unwrapCsharpCollectionAccessor, + // C# matches legacy DAG by collapsing member-call CALLS edges to // `(caller, target)` — multiple `g.Greet(...)` sites from Main // yield ONE edge, not one per site. diff --git a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts index 1424c940d..cbd6a850a 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/contract/scope-resolver.ts @@ -210,6 +210,26 @@ export interface ScopeResolver { */ readonly fieldFallbackOnMethodLookup?: boolean; + /** + * Unwrap a collection-accessor expression on a typed receiver to + * its element type. Called by `resolveCompoundReceiverClass` when + * walking dotted member-access chains like `data.Values` where + * `data` is Dictionary-like. The provider returns the element + * type's simple name, or `undefined` when the accessor doesn't + * unwrap (letting the regular field-walk resume). + * + * C#: `{ receiverType: 'Dictionary', accessor: 'Values' }` + * → `'User'`. + * Other languages (Python, Java, TypeScript) don't share C#'s + * property-access convention for Dictionary views, so leave this + * undefined and use method-call shapes (`.values()`) via the + * regular call-expression branch. + */ + readonly unwrapCollectionAccessor?: ( + receiverType: string, + accessor: string, + ) => string | undefined; + /** * Collapse member-call CALLS edges by `(caller, target)` rather * than per-site. Default `false` (scope-resolution's contract diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts index e269d8a58..1927a3d39 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -39,6 +39,14 @@ interface ResolveCompoundReceiverOptions { * class, walk its fields and try the lookup on each field's class. * Phase-9C "unified fixpoint" — Python-shaped heuristic. */ readonly fieldFallback?: boolean; + /** Language-specific accessor unwrap — `data.Values` on a + * Dictionary-typed receiver yields V (C#), etc. Returns the + * element type's simple name, or `undefined` to let the regular + * field-walk handle the access. */ + readonly unwrapCollectionAccessor?: ( + receiverType: string, + accessor: string, + ) => string | undefined; } export function resolveCompoundReceiverClass( @@ -156,23 +164,22 @@ export function resolveCompoundReceiverClass( // Pure dotted access `obj.field[.field]…` — walk fields. const parts = text.split('.'); - // Collection accessor suffix — if the final segment is - // `.Values` / `.Keys`, resolve the prefix first (recursively for - // nested cases like `this.data.Values`) and unwrap its - // Dictionary generic. Handled before the class-resolution - // step because Dictionary isn't a local class def. - const last = parts[parts.length - 1]!; - if ((last === 'Values' || last === 'Keys') && parts.length >= 2) { + // Language-specific collection-accessor suffix (C#'s `data.Values` + // on Dictionary, etc.). When the provider hook recognizes + // the final segment and unwraps the receiver's generic, return + // the element class directly. Resolved before the field-walk + // because Dictionary-family types aren't local class defs. + if (options.unwrapCollectionAccessor !== undefined && parts.length >= 2) { + const last = parts[parts.length - 1]!; const prefix = parts.slice(0, -1).join('.'); - // Find the receiver type for the prefix. Single-segment: direct - // typeBinding; multi-segment: recurse to resolve class + walk fields. let prefixType: TypeRef | undefined; if (parts.length === 2) { prefixType = findReceiverTypeBinding(inScope, prefix, scopes); } else { // Recursive resolution: walk the prefix as a dotted class chain // to find its typeRef. We need the TypeRef (not the class def) - // because we want to inspect its Dictionary generic args. + // because the hook inspects the raw generic args (e.g. + // `Dictionary`). const headInner = parts[0]!; let cur = findReceiverTypeBinding(inScope, headInner, scopes); for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) { @@ -187,9 +194,8 @@ export function resolveCompoundReceiverClass( prefixType = cur; } if (prefixType !== undefined) { - const args = extractDictionaryArgs(prefixType.rawName); - if (args !== undefined) { - const elemName = last === 'Values' ? args.value : args.key; + const elemName = options.unwrapCollectionAccessor(prefixType.rawName, last); + if (elemName !== undefined) { return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes); } } @@ -210,35 +216,6 @@ export function resolveCompoundReceiverClass( return currentClass; } -/** Extract (K, V) from `Dictionary` / `IDictionary` / - * `IReadOnlyDictionary` / `SortedDictionary`. Returns - * undefined if the type name doesn't match a Dictionary-family - * generic or the argument list isn't exactly two top-level args. */ -function extractDictionaryArgs(rawName: string): { key: string; value: string } | undefined { - const match = rawName.match( - /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:Dictionary|IDictionary|IReadOnlyDictionary|SortedDictionary|ConcurrentDictionary|ImmutableDictionary)<(.+)>$/, - ); - if (match === null) return undefined; - const inner = match[1]!; - // Split on the top-level comma (tolerate nested `<...>`). - let depth = 0; - let commaIdx = -1; - for (let i = 0; i < inner.length; i++) { - const ch = inner[i]; - if (ch === '<') depth++; - else if (ch === '>') depth--; - else if (ch === ',' && depth === 0) { - commaIdx = i; - break; - } - } - if (commaIdx === -1) return undefined; - return { - key: inner.slice(0, commaIdx).trim(), - value: inner.slice(commaIdx + 1).trim(), - }; -} - /** Find the index of the `(` that matches the trailing `)` of a * call-expression text. Returns -1 if unbalanced. */ function matchingOpenParen(text: string): number { diff --git a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts index ba77427ca..662388afa 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/receiver-bound-calls.ts @@ -52,7 +52,10 @@ import { resolveDefGraphId } from '../graph-bridge/ids.js'; * refactors lighter — callers only need to populate what we read. */ type ReceiverBoundProviderSubset = Pick< ScopeResolver, - 'isSuperReceiver' | 'fieldFallbackOnMethodLookup' | 'collapseMemberCallsByCallerTarget' + | 'isSuperReceiver' + | 'fieldFallbackOnMethodLookup' + | 'collapseMemberCallsByCallerTarget' + | 'unwrapCollectionAccessor' >; export function emitReceiverBoundCalls( @@ -184,7 +187,7 @@ export function emitReceiverBoundCalls( site.inScope, scopes, index, - { fieldFallback }, + { fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor }, ); if (currentClass !== undefined) { const chain = [currentClass.nodeId, ...scopes.methodDispatch.mroFor(currentClass.nodeId)]; @@ -302,20 +305,26 @@ export function emitReceiverBoundCalls( !typeRef.rawName.includes('(') && !namespaceTargets.has(typeRef.rawName.split('.')[0]!) ) { - // For collection-accessor suffixes (`.Values`, `.Keys`) the - // raw typeRef already describes a plain dotted access that - // `resolveCompoundReceiverClass` can walk directly — don't - // append `()` which would misroute to its call-expression - // branch. - const tail = typeRef.rawName.split('.').pop() ?? ''; - const isAccessor = tail === 'Values' || tail === 'Keys'; - const ownerDef = resolveCompoundReceiverClass( - isAccessor ? typeRef.rawName : typeRef.rawName + '()', + // Try the plain dotted-field walk first — covers property / + // collection-accessor shapes (`.Values`, Kotlin `.size`) and + // field chains. Fall back to call-form (`x()`) which treats + // the last segment as a method invocation. + let ownerDef = resolveCompoundReceiverClass( + typeRef.rawName, typeRef.declaredAtScope, scopes, index, - { fieldFallback }, + { fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor }, ); + if (ownerDef === undefined) { + ownerDef = resolveCompoundReceiverClass( + typeRef.rawName + '()', + typeRef.declaredAtScope, + scopes, + index, + { fieldFallback, unwrapCollectionAccessor: provider.unwrapCollectionAccessor }, + ); + } if (ownerDef !== undefined) { const chain = [ownerDef.nodeId, ...scopes.methodDispatch.mroFor(ownerDef.nodeId)]; let memberDef: SymbolDefinition | undefined;