diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts index 7024ed22b..beb2594a9 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -94,6 +94,12 @@ export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBi return { boundName: nameCap.text, rawTypeName: rawType, source }; } +/** Member accesses we want to preserve through qualifier stripping. + * Dictionary/collection views (`data.Values`, `data.Keys`) survive + * so the compound-receiver pass can unwrap the receiver's generic + * type (Dictionary) based on the suffix. */ +const COLLECTION_ACCESSOR_SUFFIXES = new Set(['Values', 'Keys']); + /** `User?` → `User`. */ function stripNullable(text: string): string { if (text.endsWith('?')) return text.slice(0, -1).trim(); @@ -117,9 +123,15 @@ function stripGeneric(text: string): string { return text; } -/** `System.Collections.User` → `User`. */ +/** `System.Collections.User` → `User`. Preserves dotted paths whose + * final segment is a known Dictionary/collection accessor (`.Values`, + * `.Keys`, `.Count`, etc.) so downstream resolvers can unwrap the + * receiver's generic type based on the suffix — `data.Values` → + * element type of `data`'s Dictionary. */ function stripQualifier(text: string): string { const lastDot = text.lastIndexOf('.'); if (lastDot === -1) return text; - return text.slice(lastDot + 1); + const tail = text.slice(lastDot + 1); + if (COLLECTION_ACCESSOR_SUFFIXES.has(tail)) return text; + return tail; } 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 1798a937b..e269d8a58 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/passes/compound-receiver.ts @@ -155,6 +155,46 @@ 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) { + 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. + const headInner = parts[0]!; + let cur = findReceiverTypeBinding(inScope, headInner, scopes); + for (let i = 1; i < parts.length - 1 && cur !== undefined; i++) { + const cls = findClassBindingInScope(cur.declaredAtScope, cur.rawName, scopes); + if (cls === undefined) { + cur = undefined; + break; + } + const cs = classScopeByDefId.get(cls.nodeId); + cur = cs?.typeBindings.get(parts[i]!); + } + prefixType = cur; + } + if (prefixType !== undefined) { + const args = extractDictionaryArgs(prefixType.rawName); + if (args !== undefined) { + const elemName = last === 'Values' ? args.value : args.key; + return findClassBindingInScope(prefixType.declaredAtScope, elemName, scopes); + } + } + } + const head = parts[0]!; const headType = findReceiverTypeBinding(inScope, head, scopes); let currentClass: SymbolDefinition | undefined = headType @@ -170,6 +210,35 @@ 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 2b169bfc3..432f71825 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 @@ -240,8 +240,15 @@ 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( - typeRef.rawName + '()', + isAccessor ? typeRef.rawName : typeRef.rawName + '()', typeRef.declaredAtScope, scopes, index,