mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
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:
parent
0e352ac669
commit
908798af0b
5 changed files with 279 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal file
142
gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue