refactor(scope-resolution): extract language-specific accessor unwrap to provider hook

Optimizer pass: move C# Dictionary-family `.Values`/`.Keys` handling
out of the shared `compound-receiver.ts` (where it had hardcoded
regex + accessor names) into a provider-level
`unwrapCollectionAccessor` hook. The shared pass now takes an
arbitrary language-specific unwrap function; C# supplies its
Dictionary implementation in `languages/csharp/accessor-unwrap.ts`.

Related cleanup in `receiver-bound-calls.ts` Case 3b: replace the
hardcoded `tail === 'Values' || tail === 'Keys'` accessor check with
a try-dotted-walk-first / fall-back-to-call-form strategy. This
removes the last C#-specific branch in the shared pass and makes the
logic generalize cleanly to other languages that use property-style
accessors for collection views (Kotlin `.size`, future languages).

Changes:
- `scope-resolution/contract/scope-resolver.ts`: new optional
  `unwrapCollectionAccessor(receiverType, accessor) => string | undefined`
  hook. Documented as language-specific with examples.
- `scope-resolution/passes/compound-receiver.ts`: delete
  `extractDictionaryArgs`, accept `unwrapCollectionAccessor` via
  options, call it for trailing accessor segments.
- `scope-resolution/passes/receiver-bound-calls.ts`: plumb the hook
  through to `resolveCompoundReceiverClass`, remove the
  C#-hardcoded Case 3b accessor check.
- `languages/csharp/accessor-unwrap.ts` (new): C# Dictionary-family
  regex + element-type extraction.
- `languages/csharp/scope-resolver.ts`: opt in.

Audit outcome: everything else added across the 19 C# migration
commits is either correctly scoped to `languages/csharp/` (query,
captures, namespace-siblings, receiver-binding, interpret, imports)
or correctly generic in shared paths (argumentTypes field,
collapseMemberCallsByCallerTarget flag, overload narrowing via
parameterTypes, interface-dispatch via IMPLEMENTS edges, class-like
owner extension for Interface/Struct/Record/Enum, type-tagged node
IDs, module-scope return-type lookup fallback).

175/175 C# green on both flag paths; 204/204 Python green on both
flag paths; TypeScript clean.
This commit is contained in:
Gergo Magyar 2026-04-21 22:11:56 +01:00
parent 788c10aacd
commit 591452a911
5 changed files with 123 additions and 54 deletions

View file

@ -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<K, V>` / `IDictionary<K, V>` /
* `IReadOnlyDictionary<K, V>` / `SortedDictionary<K, V>` /
* `ConcurrentDictionary<K, V>` / `ImmutableDictionary<K, V>`.
* 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;
}

View file

@ -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.

View file

@ -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<string, User>', 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

View file

@ -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<K,V>-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<K,V> 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<K,V>, 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<K,V> generic args.
// because the hook inspects the raw generic args (e.g.
// `Dictionary<string, User>`).
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<K, V>` / `IDictionary<K, V>` /
* `IReadOnlyDictionary<K, V>` / `SortedDictionary<K, V>`. 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 {

View file

@ -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;