feat(csharp-scope): parity Unit 6c — Dictionary.Values / .Keys unwrap

Closes 2 parity failures (8 → 6).

Dictionary<K,V>.Values in a foreach binds the element to V; .Keys
binds to K. Without this, `foreach (var user in data.Values)` where
`data: Dictionary<string, User>` couldn't propagate user's type to
User, and `user.Save()` stayed unresolved.

Changes:
- `languages/csharp/interpret.ts`: don't strip the qualifier when
  the final dotted segment is a known collection accessor
  (`Values` / `Keys`). Preserves the dotted form so downstream
  resolvers can unwrap the receiver's generic type based on the
  suffix.
- `scope-resolution/passes/compound-receiver.ts`: new
  `extractDictionaryArgs` helper splits `Dictionary<K, V>` at the
  top-level comma. In the dotted-access walk, detect trailing
  `.Values` / `.Keys` and return V/K via findClassBindingInScope
  instead of the normal class-walk (Dictionary itself isn't a
  local class def).
  - Handles nested cases: `this.data.Values` walks `this.data`
    recursively (resolving `data` as a field on `this`'s class)
    before applying the unwrap.
- `scope-resolution/passes/receiver-bound-calls.ts` Case 3b: when
  the typeRef's trailing segment is an accessor, pass the raw
  dotted path to `resolveCompoundReceiverClass` without appending
  `()` — the extra parens would misroute to the call-expression
  branch.

Python parity 204/204 on both flag paths; legacy C# 175/175 green;
6 C# parity failures remain.
This commit is contained in:
Gergo Magyar 2026-04-21 21:23:04 +01:00
parent 5e54fee523
commit 5ee345271f
3 changed files with 91 additions and 3 deletions

View file

@ -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<K,V>) 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<K,V>. */
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;
}

View file

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

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