Files
codegraph/src/extraction/languages/csharp.ts
T
Colby MchenryandClaude Opus 4.8 80db274e5f 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>
2026-06-07 10:50:15 -04:00

116 lines
5.0 KiB
TypeScript

import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getNodeText } from '../tree-sitter-helpers';
import type { LanguageExtractor } from '../tree-sitter-types';
/**
* Blank C# conditional-compilation directive lines (`#if` / `#elif` / `#else` /
* `#endif`) before parsing. The vendored tree-sitter-c-sharp grammar mis-parses
* a `#if` that appears *inside an enum member list* — the canonical
* multi-targeting shape:
*
* enum ReadType {
* #if HAVE_DATE_TIME_OFFSET
* ReadAsDateTimeOffset,
* #endif
* ReadAsDouble,
* }
*
* It emits an ERROR that, for a nested enum, detaches the *enclosing class's*
* member list, so most of the class's methods drop out of the index. Removing
* the directive lines (keeping the guarded code) sidesteps it. Both branches of
* an `#if/#else` are kept — the same behaviour the previous grammar produced,
* and the right default for a code graph (index every symbol regardless of
* build flags). Replacement preserves byte offsets (directive text → spaces,
* newlines kept) so every symbol's line/column stays exact. (#237)
*/
export function blankCsharpPreprocessorDirectives(source: string): string {
if (source.indexOf('#') === -1) return source;
// Conditional-compilation directives only. `#region`/`#pragma`/`#nullable`
// parse fine and are left alone. A directive must be the first non-space token
// on its line (C# requirement), so anchor to line start.
const re = /^([ \t]*)#[ \t]*(if|elif|else|endif)\b[^\n]*/gm;
return source.replace(re, (m, indent) => indent + ' '.repeat(m.length - indent.length));
}
export const csharpExtractor: LanguageExtractor = {
preParse: blankCsharpPreprocessorDirectives,
functionTypes: [],
// Records are first-class type declarations in modern C# (DTOs, value objects,
// MediatR/CQRS messages). `record` / `record class` parse as record_declaration
// (reference type → class); `record struct` as record_struct_declaration (value
// type → struct). Without these, references to a record never resolve (#237).
classTypes: ['class_declaration', 'record_declaration'],
methodTypes: ['method_declaration', 'constructor_declaration'],
interfaceTypes: ['interface_declaration'],
structTypes: ['struct_declaration', 'record_struct_declaration'],
enumTypes: ['enum_declaration'],
enumMemberTypes: ['enum_member_declaration'],
typeAliasTypes: [],
// Namespaces qualify type names so same-named types in different namespaces are
// distinguishable (e.g. `ApplicationCore.Entities.CatalogBrand` vs
// `BlazorShared.Models.CatalogBrand`). Both block (`namespace Foo { … }`, which
// nests its types) and file-scoped (`namespace Foo;`) forms — extractFilePackage
// pushes the namespace onto the scope so nested/top-level types pick it up.
packageTypes: ['namespace_declaration', 'file_scoped_namespace_declaration'],
extractPackage: (node: SyntaxNode, source: string) => {
const name =
node.childForFieldName('name') ??
node.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name' || c.type === 'identifier');
return name ? getNodeText(name, source) : null;
},
importTypes: ['using_directive'],
callTypes: ['invocation_expression'],
variableTypes: ['local_declaration_statement'],
fieldTypes: ['field_declaration'],
propertyTypes: ['property_declaration'],
nameField: 'name',
bodyField: 'body',
paramsField: 'parameters',
returnField: 'type',
getVisibility: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier') {
const text = child.text;
if (text === 'public') return 'public';
if (text === 'private') return 'private';
if (text === 'protected') return 'protected';
if (text === 'internal') return 'internal';
}
}
return 'private'; // C# defaults to private
},
isStatic: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'static') {
return true;
}
}
return false;
},
isAsync: (node) => {
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child?.type === 'modifier' && child.text === 'async') {
return true;
}
}
return false;
},
extractImport: (node, source) => {
const importText = source.substring(node.startIndex, node.endIndex).trim();
// C# using directives: using System, using System.Collections.Generic, using static X, using Alias = X
const qualifiedName = node.namedChildren.find((c: SyntaxNode) => c.type === 'qualified_name');
if (qualifiedName) {
return { moduleName: getNodeText(qualifiedName, source), signature: importText };
}
// Simple namespace like "using System;" - get the first identifier
const identifier = node.namedChildren.find((c: SyntaxNode) => c.type === 'identifier');
if (identifier) {
return { moduleName: getNodeText(identifier, source), signature: importText };
}
return null;
},
};