Compare commits

...
8 changed files with 304 additions and 21 deletions
@@ -365,6 +365,25 @@ const BUILT_IN_NAMES = new Set([
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// C# / .NET built-ins (IMPORTANT: keep in sync with parse-worker.ts BUILT_INS)
'Console', 'WriteLine', 'ReadLine', 'Write',
'Task', 'Run', 'Wait', 'WhenAll', 'WhenAny', 'FromResult', 'Delay', 'ContinueWith',
'ConfigureAwait', 'GetAwaiter', 'GetResult',
'ToString', 'GetType', 'Equals', 'GetHashCode', 'ReferenceEquals',
'Add', 'Remove', 'Contains', 'Clear', 'Count', 'Any', 'All',
'Where', 'Select', 'SelectMany', 'OrderBy', 'OrderByDescending', 'GroupBy',
'First', 'FirstOrDefault', 'Single', 'SingleOrDefault', 'Last', 'LastOrDefault',
'ToList', 'ToArray', 'ToDictionary', 'AsEnumerable', 'AsQueryable',
'Aggregate', 'Sum', 'Average', 'Min', 'Max', 'Distinct', 'Skip', 'Take',
'String', 'Format', 'IsNullOrEmpty', 'IsNullOrWhiteSpace', 'Concat', 'Join',
'Trim', 'TrimStart', 'TrimEnd', 'Split', 'Replace', 'StartsWith', 'EndsWith',
'Convert', 'ToInt32', 'ToDouble', 'ToBoolean', 'ToByte',
'Math', 'Abs', 'Ceiling', 'Floor', 'Round', 'Pow', 'Sqrt',
'Dispose', 'Close',
'TryParse', 'Parse',
'AddRange', 'RemoveAt', 'RemoveAll', 'FindAll', 'Exists', 'TrueForAll',
'ContainsKey', 'TryGetValue', 'AddOrUpdate',
'Throw', 'ThrowIfNull',
// Swift/iOS built-ins and standard library
'print', 'debugPrint', 'dump', 'fatalError', 'precondition', 'preconditionFailure',
'assert', 'assertionFailure', 'NSLog',
@@ -63,10 +63,18 @@ const ENTRY_POINT_PATTERNS: Record<string, RegExp[]> = {
// C#
'csharp': [
/^(Get|Post|Put|Delete)/, // ASP.NET conventions
/Action$/, // MVC actions
/^On[A-Z]/, // Event handlers
/Async$/, // Async entry points
/^(Get|Post|Put|Delete|Patch)/, // ASP.NET action methods
/Action$/, // MVC actions
/^On[A-Z]/, // Event handlers / Blazor lifecycle
/Async$/, // Async entry points
/^Configure$/, // Startup.Configure
/^ConfigureServices$/, // Startup.ConfigureServices
/^Handle$/, // MediatR / generic handler
/^Execute$/, // Command pattern
/^Invoke$/, // Middleware Invoke
/^Map[A-Z]/, // Minimal API MapGet, MapPost
/Service$/, // Service classes
/^Seed/, // Database seeding
],
// Go
@@ -296,8 +304,13 @@ export function isTestFile(filePath: string): boolean {
p.endsWith('test.swift') ||
p.includes('uitests/') ||
// C# test patterns
p.endsWith('tests.cs') ||
p.endsWith('test.cs') ||
p.includes('.tests/') ||
p.includes('tests.cs') ||
p.includes('.test/') ||
p.includes('.integrationtests/') ||
p.includes('.unittests/') ||
p.includes('/testproject/') ||
// PHP/Laravel test patterns
p.endsWith('test.php') ||
p.endsWith('spec.php') ||
@@ -184,6 +184,34 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null
return { framework: 'aspnet', entryPointMultiplier: 3.0, reason: 'aspnet-controller-file' };
}
// ASP.NET Services
if ((p.includes('/services/') || p.includes('/service/')) && p.endsWith('.cs')) {
return { framework: 'aspnet', entryPointMultiplier: 1.8, reason: 'aspnet-service' };
}
// ASP.NET Middleware
if (p.includes('/middleware/') && p.endsWith('.cs')) {
return { framework: 'aspnet', entryPointMultiplier: 2.5, reason: 'aspnet-middleware' };
}
// SignalR Hubs
if (p.includes('/hubs/') && p.endsWith('.cs')) {
return { framework: 'signalr', entryPointMultiplier: 2.5, reason: 'signalr-hub' };
}
if (p.endsWith('hub.cs')) {
return { framework: 'signalr', entryPointMultiplier: 2.5, reason: 'signalr-hub-file' };
}
// Minimal API / Program.cs / Startup.cs
if (p.endsWith('/program.cs') || p.endsWith('/startup.cs')) {
return { framework: 'aspnet', entryPointMultiplier: 3.0, reason: 'aspnet-entry' };
}
// Background services / Hosted services
if ((p.includes('/backgroundservices/') || p.includes('/hostedservices/')) && p.endsWith('.cs')) {
return { framework: 'aspnet', entryPointMultiplier: 2.0, reason: 'aspnet-background-service' };
}
// Blazor pages
if (p.includes('/pages/') && p.endsWith('.razor')) {
return { framework: 'blazor', entryPointMultiplier: 2.5, reason: 'blazor-page' };
@@ -385,7 +413,11 @@ export const FRAMEWORK_AST_PATTERNS = {
'jaxrs': ['@Path', '@GET', '@POST', '@PUT', '@DELETE'],
// C# attributes
'aspnet': ['[ApiController]', '[HttpGet]', '[HttpPost]', '[Route]'],
'aspnet': ['[ApiController]', '[HttpGet]', '[HttpPost]', '[HttpPut]', '[HttpDelete]',
'[Route]', '[Authorize]', '[AllowAnonymous]'],
'signalr': ['[HubMethodName]', ': Hub', ': Hub<'],
'blazor': ['@page', '[Parameter]', '@inject'],
'efcore': ['DbContext', 'DbSet<', 'OnModelCreating'],
// Go patterns (function signatures)
'go-http': ['http.Handler', 'http.HandlerFunc', 'ServeHTTP'],
@@ -435,6 +467,9 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
],
csharp: [
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
{ framework: 'signalr', entryPointMultiplier: 2.8, reason: 'signalr-attribute', patterns: FRAMEWORK_AST_PATTERNS.signalr },
{ framework: 'blazor', entryPointMultiplier: 2.5, reason: 'blazor-attribute', patterns: FRAMEWORK_AST_PATTERNS.blazor },
{ framework: 'efcore', entryPointMultiplier: 2.0, reason: 'efcore-pattern', patterns: FRAMEWORK_AST_PATTERNS.efcore },
],
php: [
{ framework: 'laravel', entryPointMultiplier: 3.0, reason: 'php-route-attribute', patterns: FRAMEWORK_AST_PATTERNS.laravel },
@@ -153,6 +153,152 @@ async function loadComposerConfig(repoRoot: string): Promise<ComposerConfig | nu
}
}
/** C# project config parsed from .csproj files */
interface CSharpProjectConfig {
/** Root namespace from <RootNamespace> or assembly name (default: project directory name) */
rootNamespace: string;
/** Directory containing the .csproj file */
projectDir: string;
}
/**
* Parse .csproj files to extract RootNamespace.
* Scans the repo root for .csproj files and returns configs for each.
*/
async function loadCSharpProjectConfig(repoRoot: string): Promise<CSharpProjectConfig[]> {
const configs: CSharpProjectConfig[] = [];
// BFS scan for .csproj files up to 5 levels deep, cap at 100 dirs to avoid runaway scanning
const scanQueue: { dir: string; depth: number }[] = [{ dir: repoRoot, depth: 0 }];
const maxDepth = 5;
const maxDirs = 100;
let dirsScanned = 0;
while (scanQueue.length > 0 && dirsScanned < maxDirs) {
const { dir, depth } = scanQueue.shift()!;
dirsScanned++;
try {
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory() && depth < maxDepth) {
// Skip common non-project directories
if (entry.name === 'node_modules' || entry.name === '.git' || entry.name === 'bin' || entry.name === 'obj') continue;
scanQueue.push({ dir: path.join(dir, entry.name), depth: depth + 1 });
}
if (entry.isFile() && entry.name.endsWith('.csproj')) {
try {
const csprojPath = path.join(dir, entry.name);
const content = await fs.readFile(csprojPath, 'utf-8');
const nsMatch = content.match(/<RootNamespace>\s*([^<]+)\s*<\/RootNamespace>/);
const rootNamespace = nsMatch
? nsMatch[1].trim()
: entry.name.replace(/\.csproj$/, '');
const projectDir = path.relative(repoRoot, dir).replace(/\\/g, '/');
configs.push({ rootNamespace, projectDir });
if (isDev) {
console.log(`📦 Loaded C# project: ${entry.name} (namespace: ${rootNamespace}, dir: ${projectDir})`);
}
} catch {
// Can't read .csproj
}
}
}
} catch {
// Can't read directory
}
}
return configs;
}
/**
* Resolve a C# using directive to file paths.
* C# `using` directives import namespaces (not files), so one using can resolve
* to multiple .cs files in a directory — similar to Go package imports.
*
* e.g. "MyApp.Services" -> all .cs files in "src/Services/"
* e.g. "MyApp.Services.UserService" -> "src/Services/UserService.cs" (single file)
*
* Strategy:
* 1. Strip root namespace prefix from each known .csproj project
* 2. Convert remaining namespace to path: Dots -> /
* 3. Try as single file first (ClassName import), then as directory (namespace import)
*/
function resolveCSharpImport(
importPath: string,
csharpConfigs: CSharpProjectConfig[],
normalizedFileList: string[],
allFileList: string[],
index?: SuffixIndex,
): string[] {
const namespacePath = importPath.replace(/\./g, '/');
const results: string[] = [];
for (const config of csharpConfigs) {
const nsPath = config.rootNamespace.replace(/\./g, '/');
let relative: string;
if (namespacePath.startsWith(nsPath + '/')) {
relative = namespacePath.slice(nsPath.length + 1);
} else if (namespacePath === nsPath) {
// The import IS the root namespace — resolve to all .cs files in project root
relative = '';
} else {
continue;
}
const dirPrefix = config.projectDir
? (relative ? config.projectDir + '/' + relative : config.projectDir)
: relative;
// 1. Try as single file: relative.cs (e.g., "Models/DlqMessage.cs")
if (relative) {
const candidate = dirPrefix + '.cs';
if (index) {
const result = index.get(candidate) || index.getInsensitive(candidate);
if (result) return [result];
}
// Also try suffix match
const suffixResult = index?.get(relative + '.cs') || index?.getInsensitive(relative + '.cs');
if (suffixResult) return [suffixResult];
}
// 2. Try as directory: all .cs files directly inside (namespace import)
if (index) {
const dirFiles = index.getFilesInDir(dirPrefix, '.cs');
for (const f of dirFiles) {
const normalized = f.replace(/\\/g, '/');
// Check it's a direct child by finding the dirPrefix and ensuring no deeper slashes
const prefixIdx = normalized.indexOf(dirPrefix + '/');
if (prefixIdx < 0) continue;
const afterDir = normalized.substring(prefixIdx + dirPrefix.length + 1);
if (!afterDir.includes('/')) {
results.push(f);
}
}
if (results.length > 0) return results;
}
// 3. Linear scan fallback for directory matching
if (results.length === 0) {
const dirTrail = dirPrefix + '/';
for (let i = 0; i < normalizedFileList.length; i++) {
const normalized = normalizedFileList[i];
if (!normalized.endsWith('.cs')) continue;
const prefixIdx = normalized.indexOf(dirTrail);
if (prefixIdx < 0) continue;
const afterDir = normalized.substring(prefixIdx + dirTrail.length);
if (!afterDir.includes('/')) {
results.push(allFileList[i]);
}
}
if (results.length > 0) return results;
}
}
// Fallback: suffix matching without namespace stripping (single file)
const pathParts = namespacePath.split('/').filter(Boolean);
const fallback = suffixResolve(pathParts, normalizedFileList, allFileList, index);
return fallback ? [fallback] : [];
}
/** Swift Package Manager module config */
interface SwiftPackageConfig {
/** Map of target name -> source directory path (e.g., "SiuperModel" -> "Package/Sources/SiuperModel") */
@@ -749,6 +895,7 @@ export const processImports = async (
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
const csharpConfigs = await loadCSharpProjectConfig(effectiveRoot);
// Helper: add an IMPORTS edge + update import map
const addImportEdge = (filePath: string, resolvedPath: string) => {
@@ -889,6 +1036,15 @@ export const processImports = async (
// Fall through if no files found (package might be external)
}
// ---- C#: handle namespace-based imports (using directives) ----
if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) {
const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index);
for (const resolvedFile of resolvedFiles) {
addImportEdge(file.path, resolvedFile);
}
return;
}
// ---- PHP: handle namespace-based imports (use statements) ----
if (language === SupportedLanguages.PHP) {
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
@@ -968,6 +1124,7 @@ export const processImportsFromExtracted = async (
const goModule = await loadGoModulePath(effectiveRoot);
const composerConfig = await loadComposerConfig(effectiveRoot);
const swiftPackageConfig = await loadSwiftPackageConfig(effectiveRoot);
const csharpConfigs = await loadCSharpProjectConfig(effectiveRoot);
const addImportEdge = (filePath: string, resolvedPath: string) => {
const sourceId = generateId('File', filePath);
@@ -1081,6 +1238,15 @@ export const processImportsFromExtracted = async (
}
}
// C#: handle namespace-based imports (using directives)
if (language === SupportedLanguages.CSharp && csharpConfigs.length > 0) {
const resolvedFiles = resolveCSharpImport(rawImportPath, csharpConfigs, normalizedFileList, allFileList, index);
for (const resolvedFile of resolvedFiles) {
addImportEdge(filePath, resolvedFile);
}
continue;
}
// PHP: handle namespace-based imports (use statements)
if (language === SupportedLanguages.PHP) {
const resolved = resolvePhpImport(rawImportPath, composerConfig, allFilePaths, normalizedFileList, allFileList, index);
@@ -282,9 +282,9 @@ export const CSHARP_QUERIES = `
; Heritage
(class_declaration name: (identifier) @heritage.class
(base_list (simple_base_type (identifier) @heritage.extends))) @heritage
(base_list (identifier) @heritage.extends)) @heritage
(class_declaration name: (identifier) @heritage.class
(base_list (simple_base_type (generic_name (identifier) @heritage.extends)))) @heritage
(base_list (generic_name (identifier) @heritage.extends))) @heritage
`;
// Rust queries - works with tree-sitter-rust
@@ -399,6 +399,25 @@ const BUILT_INS = new Set([
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// C# / .NET built-ins (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES)
'Console', 'WriteLine', 'ReadLine', 'Write',
'Task', 'Run', 'Wait', 'WhenAll', 'WhenAny', 'FromResult', 'Delay', 'ContinueWith',
'ConfigureAwait', 'GetAwaiter', 'GetResult',
'ToString', 'GetType', 'Equals', 'GetHashCode', 'ReferenceEquals',
'Add', 'Remove', 'Contains', 'Clear', 'Count', 'Any', 'All',
'Where', 'Select', 'SelectMany', 'OrderBy', 'OrderByDescending', 'GroupBy',
'First', 'FirstOrDefault', 'Single', 'SingleOrDefault', 'Last', 'LastOrDefault',
'ToList', 'ToArray', 'ToDictionary', 'AsEnumerable', 'AsQueryable',
'Aggregate', 'Sum', 'Average', 'Min', 'Max', 'Distinct', 'Skip', 'Take',
'String', 'Format', 'IsNullOrEmpty', 'IsNullOrWhiteSpace', 'Concat', 'Join',
'Trim', 'TrimStart', 'TrimEnd', 'Split', 'Replace', 'StartsWith', 'EndsWith',
'Convert', 'ToInt32', 'ToDouble', 'ToBoolean', 'ToByte',
'Math', 'Abs', 'Ceiling', 'Floor', 'Round', 'Pow', 'Sqrt',
'Dispose', 'Close',
'TryParse', 'Parse',
'AddRange', 'RemoveAt', 'RemoveAll', 'FindAll', 'Exists', 'TrueForAll',
'ContainsKey', 'TryGetValue', 'AddOrUpdate',
'Throw', 'ThrowIfNull',
// PHP built-ins
'echo', 'isset', 'empty', 'unset', 'list', 'array', 'compact', 'extract',
'count', 'strlen', 'strpos', 'strrpos', 'substr', 'strtolower', 'strtoupper', 'trim',
+38 -5
View File
@@ -1,22 +1,55 @@
using System;
using System.Collections.Generic;
namespace SampleApp
{
public class Calculator
public interface ICalculator
{
int Add(int a, int b);
}
public class Calculator : ICalculator
{
public int Result { get; private set; }
public Calculator() { Result = 0; }
public int Add(int a, int b)
{
return a + b;
Result = a + b;
LogResult(Result);
return Result;
}
private int Multiply(int a, int b)
private void LogResult(int value)
{
return a * b;
Console.WriteLine(value);
}
private int Multiply(int a, int b) { return a * b; }
}
internal class Helper
{
public void DoWork() { }
public void DoWork()
{
var calc = new Calculator();
calc.Add(1, 2);
}
}
public enum Operation
{
Add,
Subtract,
Multiply
}
public record CalculationResult(int Value, Operation Op);
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
}
@@ -153,14 +153,12 @@ describe('Tree-sitter multi-language parsing', () => {
it('parses class, method, and property declarations', async () => {
await loadLanguage(SupportedLanguages.CSharp);
const content = readFixture('simple.cs');
try {
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CSharp]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
} catch (e: any) {
// Some tree-sitter-c-sharp versions don't support all query node types
expect(e.message).toContain('TSQueryError');
}
const { matches } = parseAndQuery(parser, content, LANGUAGE_QUERIES[SupportedLanguages.CSharp]);
const defs = extractDefinitions(matches);
expect(defs.length).toBeGreaterThan(0);
const defTypes = defs.map(d => d.type);
expect(defTypes).toContain('definition.class');
expect(defTypes).toContain('definition.method');
});
});