feat(csharp-scope): parity Unit 1 — this/base receiver-binding synthesis

Closes 3 parity failures (51 → 48). Target bucket: Category C from the
parity plan.

Changes:
- `languages/csharp/receiver-binding.ts` (new): walks up from a
  function node to the enclosing class/struct/record/interface,
  synthesizes `@type-binding.self` captures with boundName `'this'`
  (and `'base'` when the enclosing type is a class/record with an
  explicit base_list entry). Skips static methods and interface /
  struct `base` cases. Anchors to the method's `body` block so the
  scope-extractor's positionIndex places the binding inside the
  function scope (not the enclosing class scope).
- `languages/csharp/captures.ts`: route `@scope.function` matches
  through the synth, emitting the receiver captures as separate
  matches.
- `languages/csharp/interpret.ts`: map `@type-binding.self` to
  `source: 'self'` (parity with Python).
- `languages/csharp/query.ts`: explicit patterns for `this.X()`,
  `base.X()`, and `this.X = ...` / `base.X = ...` assignment writes.
  `this` and `base` are anonymous tokens in tree-sitter-c-sharp so
  the existing `expression: (_)` pattern (named-only) didn't match.

Tests:
- 8 new unit tests for receiver-binding synthesis edge cases
  (class/struct/record/interface, static, nested, constructor,
  local function inside method).
- Parity: 48 failed | 127 passed (175) under REGISTRY_PRIMARY_CSHARP=1;
  legacy path 175/175 green.
This commit is contained in:
Gergo Magyar 2026-04-21 18:26:00 +01:00
parent 0e352ac669
commit 908798af0b
5 changed files with 279 additions and 2 deletions

View file

@ -20,6 +20,7 @@ import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { findNodeAtRange, nodeToCapture, syntheticCapture } from '../../utils/ast-helpers.js';
import { splitUsingDirective } from './import-decomposer.js';
import { computeCsharpArityMetadata } from './arity-metadata.js';
import { synthesizeCsharpReceiverBinding } from './receiver-binding.js';
import { getCsharpParser, getCsharpScopeQuery } from './query.js';
import { recordCacheHit, recordCacheMiss } from './cache-stats.js';
@ -90,6 +91,24 @@ export function emitCsharpScopeCaptures(
continue;
}
// Synthesize `this` / `base` receiver type-bindings on every
// instance method-like. Tree-sitter can't cleanly express "the
// implicit receiver of a non-static member of a class/struct/
// record/interface" via a static `.scm` pattern, so we walk up
// the AST in code. Mirrors Python's `self`/`cls` synthesis on
// `@scope.function` matches.
if (grouped['@scope.function'] !== undefined) {
out.push(grouped);
const anchor = grouped['@scope.function']!;
const fnNode = findFunctionNode(tree.rootNode, anchor.range);
if (fnNode !== null) {
for (const synth of synthesizeCsharpReceiverBinding(fnNode)) {
out.push(synth);
}
}
continue;
}
// Synthesize arity metadata on function-like declarations so the
// registry can narrow overloads (C# relies heavily on this). Mirrors
// Python's captures.ts pattern — one anchor per match, so we find

View file

@ -73,9 +73,11 @@ export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBi
// receiver-typed resolution treats these identically.
const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim())));
// Anchor captures distinguish the source of the binding.
// Anchor captures distinguish the source of the binding. Order
// matters: more-specific anchors take precedence.
let source: TypeRef['source'] = 'parameter-annotation';
if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
if (captures['@type-binding.self'] !== undefined) source = 'self';
else if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred';
else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation';
else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred';
else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation';

View file

@ -207,11 +207,25 @@ const CSHARP_SCOPE_QUERY = `
function: (identifier) @reference.name) @reference.call.free
;; References member calls: \`obj.Method()\`
;; \`(_)\` matches only named nodes in tree-sitter queries. \`this\` and
;; \`base\` are anonymous tokens in tree-sitter-c-sharp (unlike Python's
;; \`self\` which is a regular identifier), so they need explicit
;; patterns to emit a receiver capture.
(invocation_expression
function: (member_access_expression
expression: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
(invocation_expression
function: (member_access_expression
expression: "this" @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
(invocation_expression
function: (member_access_expression
expression: "base" @reference.receiver
name: (identifier) @reference.name)) @reference.call.member
;; References null-conditional member calls: \`obj?.Method()\`
;; conditional_access_expression wraps a receiver followed by a
;; member_binding_expression. Capture the receiver explicitly so
@ -243,6 +257,16 @@ const CSHARP_SCOPE_QUERY = `
left: (member_access_expression
expression: (_) @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
(assignment_expression
left: (member_access_expression
expression: "this" @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
(assignment_expression
left: (member_access_expression
expression: "base" @reference.receiver
name: (identifier) @reference.name)) @reference.write.member
`;
let _parser: Parser | null = null;

View file

@ -0,0 +1,142 @@
/**
* Synthesize `@type-binding.self` captures for C# instance methods
* one for `this` (always on non-static methods inside a type
* declaration) and optionally one for `base` (only on class methods
* when the enclosing class has an explicit base in its `base_list`).
*
* Mirrors `languages/python/receiver-binding.ts` in structure. The
* tree-sitter-c-sharp grammar doesn't give us a clean `.scm` pattern
* for "this-receiver on every instance method inside an enclosing
* type" because the binding isn't a parameter — it's an implicit
* receiver. Synthesis in code is the same approach Python uses for
* `self` / `cls`.
*/
import type { Capture, CaptureMatch } from 'gitnexus-shared';
import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js';
const TYPE_DECL_NODE_TYPES = new Set([
'class_declaration',
'struct_declaration',
'record_declaration',
'interface_declaration',
]);
const FUNCTION_NODE_TYPES = new Set([
'method_declaration',
'constructor_declaration',
'destructor_declaration',
'operator_declaration',
'conversion_operator_declaration',
'local_function_statement',
]);
/** Walk up to the enclosing type declaration, stopping at any other
* function-like node (nested local functions shouldn't leak `this`
* from an outer class to an inner closure). */
function findEnclosingTypeDeclaration(node: SyntaxNode): SyntaxNode | null {
let cur: SyntaxNode | null = node.parent;
while (cur !== null) {
if (TYPE_DECL_NODE_TYPES.has(cur.type)) return cur;
// A local function nested inside another method still sees `this`
// from the enclosing class — don't break on function-like nodes.
cur = cur.parent;
}
return null;
}
function typeName(typeNode: SyntaxNode): string | null {
return typeNode.childForFieldName('name')?.text ?? null;
}
/** First entry in the type's `base_list`, read as raw text. C# allows
* generic and qualified bases (`Foo<T>`, `N.M.Base`); we keep the raw
* form so downstream interpret layer can strip generics/qualifiers
* the same way as other type-binding captures. Returns null when the
* type has no base (or an empty base_list). */
function firstBaseText(typeNode: SyntaxNode): string | null {
for (let i = 0; i < typeNode.namedChildCount; i++) {
const child = typeNode.namedChild(i);
if (child === null || child.type !== 'base_list') continue;
const firstBase = child.namedChild(0);
if (firstBase === null) return null;
return firstBase.text;
}
return null;
}
function isStaticMethod(fnNode: SyntaxNode): boolean {
// A `static` modifier appears as a named `modifier` child whose text
// is exactly "static". collectModifierTexts in field-extractors
// handles this, but duplicating the tiny scan here keeps the
// receiver-binding module dependency-free.
for (let i = 0; i < fnNode.namedChildCount; i++) {
const child = fnNode.namedChild(i);
if (child !== null && child.type === 'modifier' && child.text.trim() === 'static') return true;
}
return false;
}
/**
* Build zero, one, or two `@type-binding.self` matches for `fnNode`:
*
* - Returns `null` if the function is free (no enclosing type),
* static, or the enclosing type has no resolvable name.
* - Returns one match (`this`) for non-static methods inside a
* class/struct/record/interface.
* - Returns two matches (`this` + `base`) only when the function
* lives in a `class_declaration` (or `record_declaration`) that has
* at least one base entry. Structs cannot inherit classes;
* interfaces cannot call `base.X`.
*
* The caller is responsible for guaranteeing
* `FUNCTION_NODE_TYPES.has(fnNode.type)`.
*/
export function synthesizeCsharpReceiverBinding(fnNode: SyntaxNode): CaptureMatch[] {
if (!FUNCTION_NODE_TYPES.has(fnNode.type)) return [];
if (isStaticMethod(fnNode)) return [];
const enclosingType = findEnclosingTypeDeclaration(fnNode);
if (enclosingType === null) return [];
const enclosingName = typeName(enclosingType);
if (enclosingName === null) return [];
// Anchor the synthesized captures to a node clearly *inside* the
// function's scope (not at the method's start position, which maps
// to the enclosing class scope via positionIndex). The method's
// `body` field is the block statement — its range is guaranteed to
// be inside the function scope. If the method has no body (interface
// declaration, `abstract`), skip — there's no function scope to
// attach the binding to.
const anchorNode = fnNode.childForFieldName('body');
if (anchorNode === null) return [];
const out: CaptureMatch[] = [];
out.push(buildReceiverMatch(anchorNode, 'this', enclosingName));
// `base` applies only to class / record methods with an explicit
// base class. `struct` can't inherit a class; `interface` can't
// call `base.X`. The first entry of `base_list` is the base class
// (interfaces follow); we can't statically distinguish the two here,
// but `base.X` only compiles when the first entry IS a class, so we
// trust the source — if the user wrote `base.X` in a class with
// interface-only bases, their code wouldn't compile anyway.
if (enclosingType.type === 'class_declaration' || enclosingType.type === 'record_declaration') {
const baseText = firstBaseText(enclosingType);
if (baseText !== null) {
out.push(buildReceiverMatch(anchorNode, 'base', baseText));
}
}
return out;
}
function buildReceiverMatch(anchorNode: SyntaxNode, name: string, typeText: string): CaptureMatch {
const m: Record<string, Capture> = {
'@type-binding.self': nodeToCapture('@type-binding.self', anchorNode),
'@type-binding.name': syntheticCapture('@type-binding.name', anchorNode, name),
'@type-binding.type': syntheticCapture('@type-binding.type', anchorNode, typeText),
};
return m;
}

View file

@ -266,6 +266,96 @@ describe('emitCsharpScopeCaptures — arity metadata synthesis', () => {
});
});
describe('emitCsharpScopeCaptures — receiver-binding synthesis (`this` / `base`)', () => {
it('emits `this` for an instance method inside a class', () => {
const m = findMatch('class User { public void M() { } }', (t) =>
t.includes('@type-binding.self'),
);
expect(m).toBeDefined();
expect(m!['@type-binding.name'].text).toBe('this');
expect(m!['@type-binding.type'].text).toBe('User');
});
it('emits both `this` and `base` when the class has a base class', () => {
const matches = emitCsharpScopeCaptures(
'class User : BaseModel { public void M() { base.Save(); } }',
'test.cs',
);
const receiverMatches = matches.filter((m) => '@type-binding.self' in m);
const names = receiverMatches.map((m) => m['@type-binding.name'].text).sort();
expect(names).toEqual(['base', 'this']);
const baseMatch = receiverMatches.find((m) => m['@type-binding.name'].text === 'base');
expect(baseMatch!['@type-binding.type'].text).toBe('BaseModel');
});
it('does not emit `this` or `base` for static methods', () => {
const matches = emitCsharpScopeCaptures('class User { public static void M() { } }', 'test.cs');
const receiverMatches = matches.filter((m) => '@type-binding.self' in m);
expect(receiverMatches).toHaveLength(0);
});
it('does not emit `base` for structs (they cannot inherit classes)', () => {
const matches = emitCsharpScopeCaptures('struct Point { public void M() { } }', 'test.cs');
const names = matches
.filter((m) => '@type-binding.self' in m)
.map((m) => m['@type-binding.name'].text);
expect(names).toEqual(['this']);
});
it('does not emit `base` for interface methods', () => {
const matches = emitCsharpScopeCaptures('interface IFoo { void M() { } }', 'test.cs');
const names = matches
.filter((m) => '@type-binding.self' in m)
.map((m) => m['@type-binding.name'].text);
expect(names).toEqual(['this']);
});
it('does not emit receiver bindings for free local functions (no enclosing type)', () => {
// Local functions inside a method still have `this` from the
// enclosing class — that's a normal method + local combination.
// Test the pure free case: a local function at namespace level is
// not legal C#, so we exercise the adjacent "top-level statement"
// variant: a method inside a class works fine, but the local
// function *inside* that method also sees `this` from the class.
// This test confirms synthesis doesn't produce duplicate bindings.
const matches = emitCsharpScopeCaptures(
'class User { public void M() { void Local() { } } }',
'test.cs',
);
const thisMatches = matches.filter(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
// Expect two: one for M() and one for Local() — both see `this`
// from the enclosing User class.
expect(thisMatches).toHaveLength(2);
for (const tm of thisMatches) {
expect(tm['@type-binding.type'].text).toBe('User');
}
});
it('emits `this` on constructors with the enclosing class name', () => {
const matches = emitCsharpScopeCaptures('class User { public User() { } }', 'test.cs');
const thisMatch = matches.find(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
expect(thisMatch).toBeDefined();
expect(thisMatch!['@type-binding.type'].text).toBe('User');
});
it('emits `this` with innermost type for nested class methods', () => {
const matches = emitCsharpScopeCaptures(
'class Outer { class Inner { public void M() { } } }',
'test.cs',
);
const thisMatches = matches.filter(
(m) => '@type-binding.self' in m && m['@type-binding.name'].text === 'this',
);
// M is the only instance method; its `this` binds to Inner.
expect(thisMatches).toHaveLength(1);
expect(thisMatches[0]['@type-binding.type'].text).toBe('Inner');
});
});
describe('emitCsharpScopeCaptures — references', () => {
it('captures free call invocations', () => {
const m = findMatch('class A { void M() { Foo(); } }', (t) =>