mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
feat: add CSharpExtractor for tree-sitter C# structural analysis
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+665
@@ -0,0 +1,665 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { createRequire } from "node:module";
|
||||
import { CSharpExtractor } from "../csharp-extractor.js";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// Load tree-sitter + C# grammar once
|
||||
let Parser: any;
|
||||
let Language: any;
|
||||
let csharpLang: any;
|
||||
|
||||
beforeAll(async () => {
|
||||
const mod = await import("web-tree-sitter");
|
||||
Parser = mod.Parser;
|
||||
Language = mod.Language;
|
||||
await Parser.init();
|
||||
const wasmPath = require.resolve(
|
||||
"tree-sitter-c-sharp/tree-sitter-c_sharp.wasm",
|
||||
);
|
||||
csharpLang = await Language.load(wasmPath);
|
||||
});
|
||||
|
||||
function parse(code: string) {
|
||||
const parser = new Parser();
|
||||
parser.setLanguage(csharpLang);
|
||||
const tree = parser.parse(code);
|
||||
const root = tree.rootNode;
|
||||
return { tree, parser, root };
|
||||
}
|
||||
|
||||
describe("CSharpExtractor", () => {
|
||||
const extractor = new CSharpExtractor();
|
||||
|
||||
it("has correct languageIds", () => {
|
||||
expect(extractor.languageIds).toEqual(["csharp"]);
|
||||
});
|
||||
|
||||
// ---- Methods/Constructors (mapped to functions) ----
|
||||
|
||||
describe("extractStructure - functions (methods & constructors)", () => {
|
||||
it("extracts methods with params and return types", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public string GetName(int id) {
|
||||
return "";
|
||||
}
|
||||
private void Process(string data, int count) {
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(2);
|
||||
|
||||
expect(result.functions[0].name).toBe("GetName");
|
||||
expect(result.functions[0].params).toEqual(["id"]);
|
||||
expect(result.functions[0].returnType).toBe("string");
|
||||
|
||||
expect(result.functions[1].name).toBe("Process");
|
||||
expect(result.functions[1].params).toEqual(["data", "count"]);
|
||||
expect(result.functions[1].returnType).toBe("void");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts constructors", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public Foo(string name, int value) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("Foo");
|
||||
expect(result.functions[0].params).toEqual(["name", "value"]);
|
||||
expect(result.functions[0].returnType).toBeUndefined();
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts methods with no params", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public void Run() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("Run");
|
||||
expect(result.functions[0].params).toEqual([]);
|
||||
expect(result.functions[0].returnType).toBe("void");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts methods with generic return types", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public List<string> GetItems() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("GetItems");
|
||||
expect(result.functions[0].returnType).toBe("List<string>");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct line ranges for multi-line methods", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public int Calculate(
|
||||
int a,
|
||||
int b
|
||||
) {
|
||||
int result = a + b;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].lineRange[0]).toBe(3);
|
||||
expect(result.functions[0].lineRange[1]).toBe(9);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Classes ----
|
||||
|
||||
describe("extractStructure - classes", () => {
|
||||
it("extracts class with methods, properties, and fields", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Server {
|
||||
private string _host;
|
||||
private int _port;
|
||||
public string Address { get; set; }
|
||||
public void Start() {}
|
||||
public void Stop() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Server");
|
||||
expect(result.classes[0].properties).toEqual(["_host", "_port", "Address"]);
|
||||
expect(result.classes[0].methods).toEqual(["Start", "Stop"]);
|
||||
expect(result.classes[0].lineRange[0]).toBe(2);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts empty class", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Empty {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Empty");
|
||||
expect(result.classes[0].properties).toEqual([]);
|
||||
expect(result.classes[0].methods).toEqual([]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("includes constructors in methods list", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public Foo() {}
|
||||
public void Run() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes[0].methods).toEqual(["Foo", "Run"]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Interfaces ----
|
||||
|
||||
describe("extractStructure - interfaces", () => {
|
||||
it("extracts interface with method signatures", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
interface IRepository {
|
||||
List<User> FindAll();
|
||||
User FindById(int id);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("IRepository");
|
||||
expect(result.classes[0].methods).toEqual(["FindAll", "FindById"]);
|
||||
expect(result.classes[0].properties).toEqual([]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts empty interface", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
interface IMarker {
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("IMarker");
|
||||
expect(result.classes[0].methods).toEqual([]);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Imports (using directives) ----
|
||||
|
||||
describe("extractStructure - imports", () => {
|
||||
it("extracts simple using directives", () => {
|
||||
const { tree, parser, root } = parse(`using System;
|
||||
namespace App {
|
||||
public class Foo {}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(1);
|
||||
expect(result.imports[0].source).toBe("System");
|
||||
expect(result.imports[0].specifiers).toEqual(["System"]);
|
||||
expect(result.imports[0].lineNumber).toBe(1);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts qualified using directives", () => {
|
||||
const { tree, parser, root } = parse(`using System;
|
||||
using System.Collections.Generic;
|
||||
namespace App {
|
||||
public class Foo {}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports).toHaveLength(2);
|
||||
expect(result.imports[0].source).toBe("System");
|
||||
expect(result.imports[0].specifiers).toEqual(["System"]);
|
||||
expect(result.imports[0].lineNumber).toBe(1);
|
||||
expect(result.imports[1].source).toBe("System.Collections.Generic");
|
||||
expect(result.imports[1].specifiers).toEqual(["Generic"]);
|
||||
expect(result.imports[1].lineNumber).toBe(2);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct import line numbers with gaps", () => {
|
||||
const { tree, parser, root } = parse(`using System;
|
||||
|
||||
using System.Linq;
|
||||
namespace App {
|
||||
public class Foo {}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.imports[0].lineNumber).toBe(1);
|
||||
expect(result.imports[1].lineNumber).toBe(3);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Exports ----
|
||||
|
||||
describe("extractStructure - exports", () => {
|
||||
it("exports public class, methods, constructor, and properties", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class UserService {
|
||||
private string _name;
|
||||
public int MaxRetries { get; set; }
|
||||
public UserService(string name) {
|
||||
_name = name;
|
||||
}
|
||||
public void Start() {}
|
||||
private void Helper() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exportNames = result.exports.map((e) => e.name);
|
||||
expect(exportNames).toContain("UserService"); // class
|
||||
// Constructor is also named UserService
|
||||
const userServiceExports = result.exports.filter(
|
||||
(e) => e.name === "UserService",
|
||||
);
|
||||
expect(userServiceExports.length).toBe(2); // class + constructor
|
||||
expect(exportNames).toContain("MaxRetries"); // public property
|
||||
expect(exportNames).toContain("Start");
|
||||
expect(exportNames).not.toContain("Helper");
|
||||
expect(exportNames).not.toContain("_name"); // private field
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("does not export non-public classes", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
class Internal {
|
||||
void Run() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.exports).toHaveLength(0);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("exports public fields", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Config {
|
||||
public string ApiKey;
|
||||
private int _retries;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exportNames = result.exports.map((e) => e.name);
|
||||
expect(exportNames).toContain("Config");
|
||||
expect(exportNames).toContain("ApiKey");
|
||||
expect(exportNames).not.toContain("_retries");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("exports public interface", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public interface IRepository {
|
||||
void Save();
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
const exportNames = result.exports.map((e) => e.name);
|
||||
expect(exportNames).toContain("IRepository");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Call Graph ----
|
||||
|
||||
describe("extractCallGraph", () => {
|
||||
it("extracts simple method calls", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public void Process(int data) {
|
||||
Transform(data);
|
||||
Format(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].caller).toBe("Process");
|
||||
expect(result[0].callee).toBe("Transform");
|
||||
expect(result[1].caller).toBe("Process");
|
||||
expect(result[1].callee).toBe("Format");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts qualified method calls (e.g. Console.WriteLine)", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
private void Log(string message) {
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].caller).toBe("Log");
|
||||
expect(result[0].callee).toBe("Console.WriteLine");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts object creation expressions", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public void Create() {
|
||||
var b = new Bar();
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].caller).toBe("Create");
|
||||
expect(result[0].callee).toBe("new Bar");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("tracks correct caller for constructors", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public Foo() {
|
||||
Init();
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].caller).toBe("Foo");
|
||||
expect(result[0].callee).toBe("Init");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("reports correct line numbers for calls", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
public void Run() {
|
||||
Foo();
|
||||
Bar();
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].lineNumber).toBe(4);
|
||||
expect(result[1].lineNumber).toBe(5);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("ignores calls outside methods (no caller)", () => {
|
||||
const { tree, parser, root } = parse(`namespace App {
|
||||
public class Foo {
|
||||
private string _value = String.Empty;
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractCallGraph(root);
|
||||
|
||||
// No enclosing method, so these are skipped
|
||||
expect(result).toHaveLength(0);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Namespace handling ----
|
||||
|
||||
describe("namespace handling", () => {
|
||||
it("extracts declarations from block-scoped namespace", () => {
|
||||
const { tree, parser, root } = parse(`namespace App.Services {
|
||||
public class Svc {
|
||||
public void Run() {}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Svc");
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("Run");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
|
||||
it("extracts declarations alongside file-scoped namespace", () => {
|
||||
const { tree, parser, root } = parse(`namespace App.Services;
|
||||
|
||||
public class Svc {
|
||||
public void Run() {}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
expect(result.classes).toHaveLength(1);
|
||||
expect(result.classes[0].name).toBe("Svc");
|
||||
expect(result.functions).toHaveLength(1);
|
||||
expect(result.functions[0].name).toBe("Run");
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
|
||||
// ---- Comprehensive ----
|
||||
|
||||
describe("comprehensive C# file", () => {
|
||||
it("handles a realistic C# module", () => {
|
||||
const { tree, parser, root } = parse(`using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace App.Services
|
||||
{
|
||||
public class UserService
|
||||
{
|
||||
private string _name;
|
||||
public int MaxRetries { get; set; }
|
||||
|
||||
public UserService(string name)
|
||||
{
|
||||
_name = name;
|
||||
}
|
||||
|
||||
public List<User> GetUsers(int limit)
|
||||
{
|
||||
return FetchFromDb(limit);
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
}
|
||||
|
||||
public interface IRepository
|
||||
{
|
||||
List<User> FindAll();
|
||||
User FindById(int id);
|
||||
}
|
||||
}
|
||||
`);
|
||||
const result = extractor.extractStructure(root);
|
||||
|
||||
// Functions: UserService (constructor), GetUsers, Log
|
||||
expect(result.functions).toHaveLength(3);
|
||||
expect(result.functions.map((f) => f.name).sort()).toEqual(
|
||||
["GetUsers", "Log", "UserService"].sort(),
|
||||
);
|
||||
|
||||
// Constructor has params but no return type
|
||||
const ctor = result.functions.find((f) => f.name === "UserService");
|
||||
expect(ctor?.params).toEqual(["name"]);
|
||||
expect(ctor?.returnType).toBeUndefined();
|
||||
|
||||
// GetUsers has params and generic return type
|
||||
const getUsers = result.functions.find((f) => f.name === "GetUsers");
|
||||
expect(getUsers?.params).toEqual(["limit"]);
|
||||
expect(getUsers?.returnType).toBe("List<User>");
|
||||
|
||||
// Log has params and void return type
|
||||
const log = result.functions.find((f) => f.name === "Log");
|
||||
expect(log?.params).toEqual(["message"]);
|
||||
expect(log?.returnType).toBe("void");
|
||||
|
||||
// Classes: UserService, IRepository
|
||||
expect(result.classes).toHaveLength(2);
|
||||
|
||||
const userService = result.classes.find(
|
||||
(c) => c.name === "UserService",
|
||||
);
|
||||
expect(userService).toBeDefined();
|
||||
expect(userService!.methods.sort()).toEqual(
|
||||
["GetUsers", "Log", "UserService"].sort(),
|
||||
);
|
||||
expect(userService!.properties.sort()).toEqual(
|
||||
["MaxRetries", "_name"].sort(),
|
||||
);
|
||||
|
||||
const repository = result.classes.find(
|
||||
(c) => c.name === "IRepository",
|
||||
);
|
||||
expect(repository).toBeDefined();
|
||||
expect(repository!.methods).toEqual(["FindAll", "FindById"]);
|
||||
expect(repository!.properties).toEqual([]);
|
||||
|
||||
// Imports: 2 (System, System.Collections.Generic)
|
||||
expect(result.imports).toHaveLength(2);
|
||||
expect(result.imports[0].source).toBe("System");
|
||||
expect(result.imports[0].specifiers).toEqual(["System"]);
|
||||
expect(result.imports[1].source).toBe("System.Collections.Generic");
|
||||
expect(result.imports[1].specifiers).toEqual(["Generic"]);
|
||||
|
||||
// Exports: UserService (class + constructor), MaxRetries, GetUsers, IRepository
|
||||
const exportNames = result.exports.map((e) => e.name);
|
||||
expect(exportNames).toContain("UserService");
|
||||
expect(exportNames).toContain("GetUsers");
|
||||
expect(exportNames).toContain("MaxRetries");
|
||||
expect(exportNames).toContain("IRepository");
|
||||
expect(exportNames).not.toContain("Log"); // private
|
||||
expect(exportNames).not.toContain("_name"); // private field
|
||||
|
||||
// Call graph
|
||||
const calls = extractor.extractCallGraph(root);
|
||||
|
||||
const getUsersCalls = calls.filter((e) => e.caller === "GetUsers");
|
||||
expect(getUsersCalls.some((e) => e.callee === "FetchFromDb")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const logCalls = calls.filter((e) => e.caller === "Log");
|
||||
expect(
|
||||
logCalls.some((e) => e.callee === "Console.WriteLine"),
|
||||
).toBe(true);
|
||||
|
||||
tree.delete();
|
||||
parser.delete();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,524 @@
|
||||
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
|
||||
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
|
||||
import { findChild, findChildren } from "./base-extractor.js";
|
||||
|
||||
/**
|
||||
* Extract parameter names from a C# `parameter_list` node.
|
||||
*
|
||||
* Each `parameter` child has a `name` field (identifier) and a `type` field.
|
||||
*/
|
||||
function extractParams(paramsNode: TreeSitterNode | null): string[] {
|
||||
if (!paramsNode) return [];
|
||||
const params: string[] = [];
|
||||
|
||||
const paramNodes = findChildren(paramsNode, "parameter");
|
||||
for (const param of paramNodes) {
|
||||
const nameNode = param.childForFieldName("name");
|
||||
if (nameNode) {
|
||||
params.push(nameNode.text);
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the return type text from a method_declaration node.
|
||||
*
|
||||
* In tree-sitter-c-sharp, the return type is the `returns` named field.
|
||||
* It can be a predefined_type (void, int, string), generic_name (List<T>),
|
||||
* identifier, nullable_type, etc.
|
||||
*/
|
||||
function extractReturnType(node: TreeSitterNode): string | undefined {
|
||||
const typeNode = node.childForFieldName("returns");
|
||||
if (!typeNode) return undefined;
|
||||
return typeNode.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a C# declaration node has a specific modifier.
|
||||
*
|
||||
* Unlike Java (which has a single `modifiers` container), C# tree-sitter
|
||||
* emits multiple separate `modifier` nodes as direct children of the
|
||||
* declaration. Each modifier node contains a single keyword child
|
||||
* (e.g., `public`, `private`, `static`).
|
||||
*/
|
||||
function hasModifier(node: TreeSitterNode, modifier: string): boolean {
|
||||
const modifierNodes = findChildren(node, "modifier");
|
||||
for (const mod of modifierNodes) {
|
||||
for (let i = 0; i < mod.childCount; i++) {
|
||||
const child = mod.child(i);
|
||||
if (child && child.text === modifier) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the namespace source text from a using_directive.
|
||||
*
|
||||
* Handles both simple identifiers (`using System;`) and qualified names
|
||||
* (`using System.Collections.Generic;`). For aliased usings like
|
||||
* `using Alias = Some.Namespace;`, extracts the target namespace.
|
||||
*/
|
||||
function extractUsingSource(node: TreeSitterNode): string | null {
|
||||
// Check for alias form: `using Alias = Some.Namespace;`
|
||||
const hasEquals = findChild(node, "=") !== null;
|
||||
|
||||
if (hasEquals) {
|
||||
// The target namespace is the qualified_name after the `=`
|
||||
const qualifiedName = findChild(node, "qualified_name");
|
||||
return qualifiedName ? qualifiedName.text : null;
|
||||
}
|
||||
|
||||
// Simple or qualified using
|
||||
const qualifiedName = findChild(node, "qualified_name");
|
||||
if (qualifiedName) return qualifiedName.text;
|
||||
|
||||
const identifier = findChild(node, "identifier");
|
||||
return identifier ? identifier.text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the last component of a dotted namespace path.
|
||||
* e.g. "System.Collections.Generic" -> "Generic"
|
||||
*/
|
||||
function lastComponent(path: string): string {
|
||||
const parts = path.split(".");
|
||||
return parts[parts.length - 1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the callee name from an invocation_expression node.
|
||||
*
|
||||
* Handles:
|
||||
* - Plain method call: `FetchFromDb(limit)` -> "FetchFromDb"
|
||||
* (function field is an identifier)
|
||||
* - Qualified call: `Console.WriteLine(msg)` -> "Console.WriteLine"
|
||||
* (function field is a member_access_expression)
|
||||
*/
|
||||
function extractInvocationName(node: TreeSitterNode): string | null {
|
||||
const funcNode = node.childForFieldName("function");
|
||||
if (!funcNode) return null;
|
||||
return funcNode.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* C# extractor for tree-sitter structural analysis and call graph extraction.
|
||||
*
|
||||
* Handles classes, interfaces, methods, constructors, properties, fields,
|
||||
* using directives, visibility-based exports, and call graphs for C# source code.
|
||||
*
|
||||
* C#-specific mapping decisions:
|
||||
* - Classes and interfaces are mapped to the `classes` array.
|
||||
* - Constructors are mapped to the `functions` array (named after the class).
|
||||
* - Methods (including interface method signatures) are listed in the
|
||||
* containing class/interface's `methods` array and also in the `functions` array.
|
||||
* - Properties (e.g., `public string Name { get; set; }`) are extracted into
|
||||
* the containing class's `properties` array alongside fields.
|
||||
* - Exports are determined by the `public` modifier on classes, interfaces,
|
||||
* methods, constructors, properties, and fields.
|
||||
* - Namespaces: both block-scoped (`namespace Foo { ... }`) and file-scoped
|
||||
* (`namespace Foo;`) are traversed to find declarations.
|
||||
* - Using directives are mapped to imports, with the last dotted component
|
||||
* as the specifier.
|
||||
*/
|
||||
export class CSharpExtractor implements LanguageExtractor {
|
||||
readonly languageIds = ["csharp"];
|
||||
|
||||
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
|
||||
const functions: StructuralAnalysis["functions"] = [];
|
||||
const classes: StructuralAnalysis["classes"] = [];
|
||||
const imports: StructuralAnalysis["imports"] = [];
|
||||
const exports: StructuralAnalysis["exports"] = [];
|
||||
|
||||
this.walkTopLevel(rootNode, functions, classes, imports, exports);
|
||||
|
||||
return { functions, classes, imports, exports };
|
||||
}
|
||||
|
||||
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
|
||||
const entries: CallGraphEntry[] = [];
|
||||
const functionStack: string[] = [];
|
||||
|
||||
const walkForCalls = (node: TreeSitterNode) => {
|
||||
let pushedName = false;
|
||||
|
||||
// Track entering method/constructor declarations
|
||||
if (
|
||||
node.type === "method_declaration" ||
|
||||
node.type === "constructor_declaration"
|
||||
) {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (nameNode) {
|
||||
functionStack.push(nameNode.text);
|
||||
pushedName = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Extract method invocations: e.g. FetchFromDb(limit), Console.WriteLine(msg)
|
||||
if (node.type === "invocation_expression") {
|
||||
if (functionStack.length > 0) {
|
||||
const callee = extractInvocationName(node);
|
||||
if (callee) {
|
||||
entries.push({
|
||||
caller: functionStack[functionStack.length - 1],
|
||||
callee,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract object creation: e.g. new Foo()
|
||||
if (node.type === "object_creation_expression") {
|
||||
if (functionStack.length > 0) {
|
||||
// The type is the child after `new` — can be identifier or generic_name
|
||||
const typeNode = findChild(node, "identifier") ?? findChild(node, "generic_name");
|
||||
if (typeNode) {
|
||||
entries.push({
|
||||
caller: functionStack[functionStack.length - 1],
|
||||
callee: `new ${typeNode.text}`,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (child) walkForCalls(child);
|
||||
}
|
||||
|
||||
if (pushedName) {
|
||||
functionStack.pop();
|
||||
}
|
||||
};
|
||||
|
||||
walkForCalls(rootNode);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// ---- Private helpers ----
|
||||
|
||||
/**
|
||||
* Walk the top-level nodes of a compilation_unit, recursing into
|
||||
* namespace bodies to find declarations.
|
||||
*/
|
||||
private walkTopLevel(
|
||||
node: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
imports: StructuralAnalysis["imports"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
for (let i = 0; i < node.childCount; i++) {
|
||||
const child = node.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case "using_directive":
|
||||
this.extractUsing(child, imports);
|
||||
break;
|
||||
|
||||
case "namespace_declaration":
|
||||
// Recurse into namespace body (declaration_list)
|
||||
this.walkNamespaceBody(child, functions, classes, imports, exports);
|
||||
break;
|
||||
|
||||
case "file_scoped_namespace_declaration":
|
||||
// File-scoped namespace: declarations are siblings at the root,
|
||||
// not children of this node. Nothing to recurse into.
|
||||
break;
|
||||
|
||||
case "class_declaration":
|
||||
this.extractClass(child, functions, classes, exports);
|
||||
break;
|
||||
|
||||
case "interface_declaration":
|
||||
this.extractInterface(child, functions, classes, exports);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk into a namespace_declaration's body (declaration_list) to find
|
||||
* classes, interfaces, and nested namespaces.
|
||||
*/
|
||||
private walkNamespaceBody(
|
||||
nsNode: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
imports: StructuralAnalysis["imports"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const body = nsNode.childForFieldName("body");
|
||||
if (!body) return;
|
||||
|
||||
for (let i = 0; i < body.childCount; i++) {
|
||||
const child = body.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case "class_declaration":
|
||||
this.extractClass(child, functions, classes, exports);
|
||||
break;
|
||||
|
||||
case "interface_declaration":
|
||||
this.extractInterface(child, functions, classes, exports);
|
||||
break;
|
||||
|
||||
case "namespace_declaration":
|
||||
// Nested namespaces
|
||||
this.walkNamespaceBody(child, functions, classes, imports, exports);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractUsing(
|
||||
node: TreeSitterNode,
|
||||
imports: StructuralAnalysis["imports"],
|
||||
): void {
|
||||
const source = extractUsingSource(node);
|
||||
if (!source) return;
|
||||
|
||||
imports.push({
|
||||
source,
|
||||
specifiers: [lastComponent(source)],
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
|
||||
private extractClass(
|
||||
node: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (!nameNode) return;
|
||||
|
||||
const methods: string[] = [];
|
||||
const properties: string[] = [];
|
||||
|
||||
const body = node.childForFieldName("body");
|
||||
if (body) {
|
||||
this.extractClassBodyMembers(body, methods, properties, functions, exports);
|
||||
}
|
||||
|
||||
classes.push({
|
||||
name: nameNode.text,
|
||||
lineRange: [
|
||||
node.startPosition.row + 1,
|
||||
node.endPosition.row + 1,
|
||||
],
|
||||
methods,
|
||||
properties,
|
||||
});
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractInterface(
|
||||
node: TreeSitterNode,
|
||||
functions: StructuralAnalysis["functions"],
|
||||
classes: StructuralAnalysis["classes"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (!nameNode) return;
|
||||
|
||||
const methods: string[] = [];
|
||||
const properties: string[] = [];
|
||||
|
||||
const body = node.childForFieldName("body");
|
||||
if (body) {
|
||||
// Interface body contains method_declaration nodes (signatures without bodies)
|
||||
const methodNodes = findChildren(body, "method_declaration");
|
||||
for (const methodNode of methodNodes) {
|
||||
const methNameNode = methodNode.childForFieldName("name");
|
||||
if (methNameNode) {
|
||||
methods.push(methNameNode.text);
|
||||
}
|
||||
}
|
||||
|
||||
// Interface can contain property_declaration nodes
|
||||
const propNodes = findChildren(body, "property_declaration");
|
||||
for (const propNode of propNodes) {
|
||||
const propNameNode = propNode.childForFieldName("name");
|
||||
if (propNameNode) {
|
||||
properties.push(propNameNode.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
classes.push({
|
||||
name: nameNode.text,
|
||||
lineRange: [
|
||||
node.startPosition.row + 1,
|
||||
node.endPosition.row + 1,
|
||||
],
|
||||
methods,
|
||||
properties,
|
||||
});
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract methods, constructors, properties, and fields from a
|
||||
* class declaration_list body.
|
||||
*/
|
||||
private extractClassBodyMembers(
|
||||
body: TreeSitterNode,
|
||||
methods: string[],
|
||||
properties: string[],
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
for (let i = 0; i < body.childCount; i++) {
|
||||
const child = body.child(i);
|
||||
if (!child) continue;
|
||||
|
||||
switch (child.type) {
|
||||
case "method_declaration":
|
||||
this.extractMethod(child, methods, functions, exports);
|
||||
break;
|
||||
|
||||
case "constructor_declaration":
|
||||
this.extractConstructor(child, methods, functions, exports);
|
||||
break;
|
||||
|
||||
case "property_declaration":
|
||||
this.extractProperty(child, properties, exports);
|
||||
break;
|
||||
|
||||
case "field_declaration":
|
||||
this.extractField(child, properties, exports);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extractMethod(
|
||||
node: TreeSitterNode,
|
||||
methods: string[],
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (!nameNode) return;
|
||||
|
||||
const paramsNode = node.childForFieldName("parameters");
|
||||
const params = extractParams(paramsNode ?? null);
|
||||
const returnType = extractReturnType(node);
|
||||
|
||||
methods.push(nameNode.text);
|
||||
|
||||
functions.push({
|
||||
name: nameNode.text,
|
||||
lineRange: [
|
||||
node.startPosition.row + 1,
|
||||
node.endPosition.row + 1,
|
||||
],
|
||||
params,
|
||||
returnType,
|
||||
});
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractConstructor(
|
||||
node: TreeSitterNode,
|
||||
methods: string[],
|
||||
functions: StructuralAnalysis["functions"],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (!nameNode) return;
|
||||
|
||||
const paramsNode = node.childForFieldName("parameters");
|
||||
const params = extractParams(paramsNode ?? null);
|
||||
|
||||
methods.push(nameNode.text);
|
||||
|
||||
functions.push({
|
||||
name: nameNode.text,
|
||||
lineRange: [
|
||||
node.startPosition.row + 1,
|
||||
node.endPosition.row + 1,
|
||||
],
|
||||
params,
|
||||
// Constructors have no return type
|
||||
});
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractProperty(
|
||||
node: TreeSitterNode,
|
||||
properties: string[],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
const nameNode = node.childForFieldName("name");
|
||||
if (!nameNode) return;
|
||||
|
||||
properties.push(nameNode.text);
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private extractField(
|
||||
node: TreeSitterNode,
|
||||
properties: string[],
|
||||
exports: StructuralAnalysis["exports"],
|
||||
): void {
|
||||
// field_declaration -> variable_declaration -> variable_declarator(s)
|
||||
const varDecl = findChild(node, "variable_declaration");
|
||||
if (!varDecl) return;
|
||||
|
||||
const declarators = findChildren(varDecl, "variable_declarator");
|
||||
for (const decl of declarators) {
|
||||
// variable_declarator's first child is the identifier
|
||||
const nameNode = findChild(decl, "identifier");
|
||||
if (nameNode) {
|
||||
properties.push(nameNode.text);
|
||||
|
||||
if (hasModifier(node, "public")) {
|
||||
exports.push({
|
||||
name: nameNode.text,
|
||||
lineNumber: node.startPosition.row + 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user