feat(csharp): index C# 12 primary constructors via an up-to-date grammar (#237) (#717)

Vendor tree-sitter-c-sharp 0.23.5 (ABI 15) for C#, replacing the bundled ABI-13
build that dropped primary-constructor classes. Adds native primary-ctor
parsing, primary-ctor parameter dependency edges, return-type extraction via the
renamed `returns` field, and a preParse that blanks `#if` directive lines the
new grammar mis-parses inside enum bodies. Validated on MediatR / eShopOnWeb /
Newtonsoft.Json + full suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-06-07 10:50:15 -04:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 2f50473aaa
commit 80db274e5f
8 changed files with 206 additions and 4 deletions
+80
View File
@@ -1013,6 +1013,86 @@ public class OrderService
expect(classNode?.name).toBe('OrderService');
expect(classNode?.visibility).toBe('public');
});
it('indexes primary-constructor classes, including keyed-DI attribute params (#237)', () => {
// C# 12 primary constructors (`class Foo(IDep dep) { … }`) are parsed
// natively by the vendored tree-sitter-c-sharp 0.23.x grammar. The worst
// shape under the previous (older) grammar — an attribute-with-args on a
// ctor param (`[FromKeyedServices("primary")] …`, the ASP.NET keyed-DI
// pattern) — used to parse as an ERROR that swallowed the whole class, so
// the class and all its methods vanished. They now index in every case.
const code = `
public class DataService(IMemoryCache cache)
{
public void Warm() { }
}
public class InstanceService(InstanceManager m, ProfileManager p)
{
public void DeployAndLaunchAsync() { }
public void Deploy() { }
}
public partial class UpdateService(int x) : ILifetimeService
{
public void Run() { }
}
public class K1KeyedDi([FromKeyedServices("primary")] IMemoryCache cache)
{
public void Warm() { }
}
public record CatalogBrand(int Id, string Name);
`;
const result = extractFromSource('Services.cs', code);
const classNames = result.nodes.filter((n) => n.kind === 'class').map((n) => n.name);
expect(classNames).toContain('DataService');
expect(classNames).toContain('InstanceService');
expect(classNames).toContain('UpdateService'); // partial + base list
expect(classNames).toContain('K1KeyedDi'); // attribute-arg ctor param — used to vanish entirely
expect(classNames).toContain('CatalogBrand'); // record
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
expect(methods).toContain('DeployAndLaunchAsync');
expect(methods).toContain('Deploy');
expect(methods).toContain('Run');
});
it('keeps a class indexable when a nested enum has #if-guarded members (#237)', () => {
// A `#if` directive inside an enum member list (the multi-targeting pattern
// in libraries like Newtonsoft.Json) makes the grammar emit an ERROR that,
// for a nested enum, detaches the enclosing class's member list — dropping
// most of the class's methods. A pre-parse pass blanks the directive lines
// (keeping both branches), so the class and all its methods still index.
const code = `
public class Reader
{
private enum ReadType
{
#if HAVE_DATE_TIME_OFFSET
ReadAsDateTimeOffset,
#endif
ReadAsDouble,
ReadAsString,
}
public void Open() { }
public void Close() { }
public int ReadInt() { return 0; }
}
`;
const result = extractFromSource('Reader.cs', code);
const methods = result.nodes.filter((n) => n.kind === 'method').map((n) => n.name);
// All three methods after the #if-bearing enum must survive.
expect(methods).toContain('Open');
expect(methods).toContain('Close');
expect(methods).toContain('ReadInt');
// Both enum branches are kept.
const enumMembers = result.nodes.filter((n) => n.kind === 'enum_member').map((n) => n.name);
expect(enumMembers).toContain('ReadAsDateTimeOffset');
expect(enumMembers).toContain('ReadAsDouble');
});
});
describe('PHP Extraction', () => {