diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 834214aae..82a39a6d9 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -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 diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts index 4f8182aa1..34f4702c5 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -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'; diff --git a/gitnexus/src/core/ingestion/languages/csharp/query.ts b/gitnexus/src/core/ingestion/languages/csharp/query.ts index 32278f92e..39501e702 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/query.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/query.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts b/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts new file mode 100644 index 000000000..9cd69b99a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/receiver-binding.ts @@ -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`, `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 = { + '@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; +} diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts index 6dca1f6fb..598d48089 100644 --- a/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-captures.test.ts @@ -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) =>