GitNexus/gitnexus/test/unit/field-extraction.test.ts
Gergő Magyar c72890d59d
feat(csharp): C# MethodExtractor config (#582)
* feat(csharp): add C# MethodExtractor config (#573)

Add C# method extraction config mirroring the JVM pattern from PR #576.
Wire csharpMethodConfig into the C# language provider and add 18 tests
covering classes, interfaces, abstract classes, structs, records,
constructors, params/out/ref/optional parameters, sealed methods,
attributes, and visibility modifiers.

* fix(csharp): add destructor, operator, conversion operator, and in-param support

- Add destructor_declaration, operator_declaration, and
  conversion_operator_declaration to methodNodeTypes
- Custom extractName for operators (e.g., "operator +", "implicit operator double")
- Fix extractReturnType for operator declarations (use type field, not returns)
- Add in modifier to parameter extraction (alongside out/ref)
- Add 4 new tests: destructor, operator+, implicit conversion, in parameter

* fix(csharp): add ref param test and document compound visibility limitation

- Add test for ref parameter modifier (was only testing out)
- Document that protected internal / private protected resolve to first modifier

* feat(csharp): support compound visibilities (protected internal, private protected)

- Add 'protected internal' and 'private protected' to FieldVisibility union
- Detect compound modifiers in both C# method and field extractors via
  collectModifierTexts helper scanning adjacent modifier nodes
- Add 2 tests for compound visibility detection

* feat(csharp): primary constructors, virtual/override/async, primary fields

Address all known limitations from review:

- Primary constructor support (C# 12): add extractPrimaryConstructor to
  MethodExtractionConfig and extractPrimaryFields to FieldExtractionConfig.
  Record params become public readonly properties; class params become
  private captured fields.
- Add isVirtual, isOverride, isAsync optional fields to MethodInfo,
  MethodExtractionConfig, NodeProperties, and parse-worker propagation.
- Detect virtual/override/async modifiers in C# method config.
- Move collectModifierTexts to shared helpers.ts (deduplicate).
- Fix destructor name to ~ClassName (disambiguates from constructor).
- Add expression-bodied method test.
- 118 tests total across method + field extraction suites, all passing.

* fix(csharp): review round 2 — annotations, record_struct, grammar pin

- Fix primary constructor annotations: use [] instead of extracting
  class-level attributes (C# has no syntax for ctor-specific attributes)
- Add record_struct_declaration to typeDeclarationNodes in both method
  and field extractors, CLASS_CONTAINER_TYPES, and isRecord visibility check
- Pin tree-sitter-c-sharp version (^0.23.1) in params comment

* fix(csharp): complete record_struct query + label mapping, sealed override test

- Add record_struct_declaration capture patterns to tree-sitter-queries.ts
  (type definition + primary constructor)
- Add record_struct_declaration → 'Struct' in CONTAINER_TYPE_TO_LABEL
- Assert isOverride: true alongside isFinal in sealed override test

* fix(csharp): record_struct label mismatch, add record struct + documented limitation tests

- Fix record_struct_declaration query tag: @definition.struct (not @definition.record)
  to match CONTAINER_TYPE_TO_LABEL and prevent broken HAS_METHOD edges
- Add 3 record struct tests: isTypeDeclaration, method extraction, primary constructor
- Add documented limitation tests: partial method (isAbstract: false), generic type
  parameter stripping (name excludes <T>)

* fix(csharp): remove record_struct_declaration — not a real tree-sitter node type

tree-sitter-c-sharp 0.23.1 parses 'record struct' as record_declaration
(absorbs the 'struct' keyword as an unnamed child token). The non-existent
record_struct_declaration in queries caused TSQueryErrorNodeType, breaking
ALL C# file processing.

Remove from: tree-sitter-queries.ts, typeDeclarationNodes in both
extractors, CLASS_CONTAINER_TYPES, and CONTAINER_TYPE_TO_LABEL.
Record struct types are already handled via record_declaration.

* feat(csharp): add isPartial support, filter targeted attributes, static ctor test

- Add isPartial optional field to MethodInfo, MethodExtractionConfig,
  NodeProperties, and parse-worker propagation pipeline
- Detect partial modifier in C# config — marks both declaration-only
  and implemented partial methods
- Filter targeted attribute lists (e.g. [return: MarshalAs(...)]) in
  extractCSharpAnnotations — only untargeted attributes collected
- Add static constructor test (isStatic: true, same name as class)
- Add 3 partial method tests: declaration-only, with body, coexisting pair
- Document record_struct/record_class as defensive dead code in
  export-detection.ts (grammar absorbs keywords into record_declaration)

* fix(csharp): this param for extension methods, dedup visibility, test fixes

- Handle this modifier on extension method parameters (type prefixed
  as 'this string', consistent with out/ref/in handling)
- Deduplicate visibility logic in extractPrimaryConstructor — reuse
  csharpMethodConfig.extractVisibility instead of inline compound check
- Fix record struct test title to reflect actual grammar behavior
- Add conversion operator returnType assertion
- Add extension method this parameter test

* fix(csharp): primary constructor line points to param list, empty name guard

- Use paramList.startPosition instead of ownerNode.startPosition for
  primary constructor line number (avoids methodInfoCache key collision)
- Guard against empty param names from tree-sitter error recovery nodes
2026-03-30 08:41:17 +01:00

1008 lines
35 KiB
TypeScript

import { describe, it, expect, beforeEach } from 'vitest';
import { TypeScriptFieldExtractor } from '../../src/core/ingestion/field-extractors/typescript.js';
import { createFieldExtractor } from '../../src/core/ingestion/field-extractors/generic.js';
import { typescriptConfig } from '../../src/core/ingestion/field-extractors/configs/typescript-javascript.js';
import { pythonConfig } from '../../src/core/ingestion/field-extractors/configs/python.js';
import { goConfig } from '../../src/core/ingestion/field-extractors/configs/go.js';
import { cppConfig } from '../../src/core/ingestion/field-extractors/configs/c-cpp.js';
import { rubyConfig } from '../../src/core/ingestion/field-extractors/configs/ruby.js';
import type { FieldExtractorContext } from '../../src/core/ingestion/field-types.js';
import type { TypeEnvironment } from '../../src/core/ingestion/type-env.js';
import { createSymbolTable } from '../../src/core/ingestion/symbol-table.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import Python from 'tree-sitter-python';
import Go from 'tree-sitter-go';
import Cpp from 'tree-sitter-cpp';
import Ruby from 'tree-sitter-ruby';
import CSharp from 'tree-sitter-c-sharp';
import { csharpConfig as csharpFieldConfig } from '../../src/core/ingestion/field-extractors/configs/csharp.js';
import { SupportedLanguages } from '../../src/config/supported-languages.js';
const parser = new Parser();
const parse = (code: string) => {
parser.setLanguage(TypeScript.typescript);
return parser.parse(code);
};
// Mock context for tests
const createMockContext = (): FieldExtractorContext => ({
typeEnv: {
lookup: () => undefined,
constructorBindings: [],
fileScope: () => new Map(),
allScopes: () => new Map(),
constructorTypeMap: new Map(),
} as TypeEnvironment,
symbolTable: createSymbolTable(),
filePath: 'test.ts',
language: SupportedLanguages.TypeScript,
});
describe('TypeScriptFieldExtractor', () => {
let extractor: TypeScriptFieldExtractor;
let mockContext: FieldExtractorContext;
beforeEach(() => {
extractor = new TypeScriptFieldExtractor();
mockContext = createMockContext();
});
describe('isTypeDeclaration', () => {
it('recognizes class_declaration', () => {
const tree = parse('class User {}');
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
});
it('recognizes interface_declaration', () => {
const tree = parse('interface IUser {}');
const interfaceNode = tree.rootNode.child(0);
expect(interfaceNode).toBeDefined();
expect(extractor.isTypeDeclaration(interfaceNode!)).toBe(true);
});
it('recognizes abstract_class_declaration', () => {
const tree = parse('abstract class BaseService {}');
const abstractNode = tree.rootNode.child(0);
expect(abstractNode).toBeDefined();
expect(extractor.isTypeDeclaration(abstractNode!)).toBe(true);
});
it('rejects function_declaration', () => {
const tree = parse('function getUser() {}');
const functionNode = tree.rootNode.child(0);
expect(functionNode).toBeDefined();
expect(extractor.isTypeDeclaration(functionNode!)).toBe(false);
});
it('rejects variable declaration', () => {
const tree = parse('const user = {};');
const variableNode = tree.rootNode.child(0);
expect(variableNode).toBeDefined();
expect(extractor.isTypeDeclaration(variableNode!)).toBe(false);
});
});
describe('extract', () => {
it('extracts single field with type', () => {
const tree = parse(`
class User {
name: string;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('User');
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('name');
expect(result!.fields[0].type).toBe('string');
expect(result!.fields[0].visibility).toBe('public');
});
it('extracts private field', () => {
const tree = parse(`
class User {
private password: string;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('password');
expect(result!.fields[0].type).toBe('string');
expect(result!.fields[0].visibility).toBe('private');
});
it('extracts static readonly field', () => {
const tree = parse(`
class Config {
static readonly VERSION: string = '1.0';
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('VERSION');
expect(result!.fields[0].type).toBe('string');
expect(result!.fields[0].isStatic).toBe(true);
expect(result!.fields[0].isReadonly).toBe(true);
expect(result!.fields[0].visibility).toBe('public');
});
it('extracts optional field (?:)', () => {
const tree = parse(`
interface User {
email?: string;
}
`);
const interfaceNode = tree.rootNode.child(0);
const result = extractor.extract(interfaceNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('email');
// Note: optional fields may have type modified to include undefined
expect(result!.fields[0].type).toContain('string');
});
it('extracts multiple fields with different visibilities', () => {
const tree = parse(`
class User {
public id: number;
private secretKey: string;
protected createdAt: Date;
name: string;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(4);
const fields = result!.fields;
const idField = fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.visibility).toBe('public');
expect(idField!.type).toBe('number');
const secretKeyField = fields.find((f) => f.name === 'secretKey');
expect(secretKeyField).toBeDefined();
expect(secretKeyField!.visibility).toBe('private');
const createdAtField = fields.find((f) => f.name === 'createdAt');
expect(createdAtField).toBeDefined();
expect(createdAtField!.visibility).toBe('protected');
const nameField = fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.visibility).toBe('public'); // default
});
it('handles field without type annotation', () => {
const tree = parse(`
class User {
name;
age;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBeNull();
const ageField = result!.fields.find((f) => f.name === 'age');
expect(ageField).toBeDefined();
expect(ageField!.type).toBeNull();
});
it('extracts complex generic types (Map<string, User>, Array<number>)', () => {
const tree = parse(`
class Repository {
users: Map<string, User>;
ids: Array<number>;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
const usersField = result!.fields.find((f) => f.name === 'users');
expect(usersField).toBeDefined();
expect(usersField!.type).toBe('Map<string, User>');
const idsField = result!.fields.find((f) => f.name === 'ids');
expect(idsField).toBeDefined();
expect(idsField!.type).toBe('Array<number>');
});
it('extracts nested types', () => {
const tree = parse(`
class Container {
data: OuterType<InnerType<string>>;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('data');
expect(result!.fields[0].type).toBe('OuterType<InnerType<string>>');
});
it('extracts fields from interface', () => {
const tree = parse(`
interface UserDTO {
id: number;
name: string;
email?: string;
}
`);
const interfaceNode = tree.rootNode.child(0);
const result = extractor.extract(interfaceNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('UserDTO');
expect(result!.fields).toHaveLength(3);
const idField = result!.fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.type).toBe('number');
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBe('string');
const emailField = result!.fields.find((f) => f.name === 'email');
expect(emailField).toBeDefined();
});
it('extracts fields from abstract class', () => {
const tree = parse(`
abstract class BaseEntity {
protected id: number;
createdAt: Date;
}
`);
const abstractNode = tree.rootNode.child(0);
const result = extractor.extract(abstractNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('BaseEntity');
expect(result!.fields).toHaveLength(2);
const idField = result!.fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.visibility).toBe('protected');
const createdAtField = result!.fields.find((f) => f.name === 'createdAt');
expect(createdAtField).toBeDefined();
expect(createdAtField!.visibility).toBe('public');
});
it('extracts array types', () => {
const tree = parse(`
class UserService {
users: User[];
ids: number[];
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
const usersField = result!.fields.find((f) => f.name === 'users');
expect(usersField).toBeDefined();
expect(usersField!.type).toBe('User[]');
const idsField = result!.fields.find((f) => f.name === 'ids');
expect(idsField).toBeDefined();
expect(idsField!.type).toBe('number[]');
});
it('extracts union types', () => {
const tree = parse(`
class Field {
value: string | number | null;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('value');
expect(result!.fields[0].type).toBe('string | number | null');
});
it('returns null for non-type declaration nodes', () => {
const tree = parse('function getUser() {}');
const functionNode = tree.rootNode.child(0);
const result = extractor.extract(functionNode!, mockContext);
expect(result).toBeNull();
});
it('extracts fields from type alias with object type', () => {
const tree = parse(`
type UserDTO = {
id: number;
name: string;
}
`);
const typeAliasNode = tree.rootNode.child(0);
const result = extractor.extract(typeAliasNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('UserDTO');
expect(result!.fields).toHaveLength(2);
const idField = result!.fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.type).toBe('number');
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBe('string');
});
it('includes source file path in field info', () => {
const tree = parse(`
class User {
name: string;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].sourceFile).toBe('test.ts');
});
it('includes line number in field info', () => {
const tree = parse(`
class User {
name: string;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].line).toBeGreaterThan(0);
});
it('detects nested interface declarations in methods', () => {
const tree = parse(`
class Container {
data: string;
process() {
interface LocalInterface {
value: number;
}
}
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('Container');
// Note: Nested types within method bodies are detected
expect(result!.nestedTypes).toContain('LocalInterface');
// Should only extract fields from the outer class
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('data');
});
});
});
// ---------------------------------------------------------------------------
// Generic factory tests — TypeScript config
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — TypeScript config', () => {
const parser = new Parser();
const extractor = createFieldExtractor(typescriptConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.TypeScript;
it('extracts public and private fields from a class', () => {
parser.setLanguage(TypeScript.typescript);
const tree = parser.parse(`
class User {
public name: string;
private age: number;
}
`);
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('User');
expect(result!.fields).toHaveLength(2);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.visibility).toBe('public');
expect(nameField!.type).toBe('string');
const ageField = result!.fields.find((f) => f.name === 'age');
expect(ageField).toBeDefined();
expect(ageField!.visibility).toBe('private');
expect(ageField!.type).toBe('number');
});
it('uses body-discovery fallback when body type does not match config', () => {
parser.setLanguage(TypeScript.typescript);
// interface_declaration has an interface_body — which IS in the config bodyNodeTypes
// but type_alias_declaration has an object_type body which is also in bodyNodeTypes
const tree = parser.parse(`
interface Settings {
theme: string;
debug: boolean;
}
`);
const ifaceNode = tree.rootNode.child(0);
expect(ifaceNode).toBeDefined();
expect(extractor.isTypeDeclaration(ifaceNode!)).toBe(true);
const result = extractor.extract(ifaceNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('Settings');
expect(result!.fields).toHaveLength(2);
expect(result!.fields.map((f) => f.name)).toContain('theme');
expect(result!.fields.map((f) => f.name)).toContain('debug');
});
it('returns null for non-type-declaration nodes', () => {
parser.setLanguage(TypeScript.typescript);
const tree = parser.parse('function hello() {}');
const fnNode = tree.rootNode.child(0);
expect(extractor.isTypeDeclaration(fnNode!)).toBe(false);
expect(extractor.extract(fnNode!, mockContext)).toBeNull();
});
it('extracts static and readonly modifiers', () => {
parser.setLanguage(TypeScript.typescript);
const tree = parser.parse(`
class Config {
static readonly MAX: number;
private count: number;
}
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
const maxField = result!.fields.find((f) => f.name === 'MAX');
expect(maxField).toBeDefined();
expect(maxField!.isStatic).toBe(true);
expect(maxField!.isReadonly).toBe(true);
const countField = result!.fields.find((f) => f.name === 'count');
expect(countField).toBeDefined();
expect(countField!.isStatic).toBe(false);
expect(countField!.isReadonly).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Python config
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — Python', () => {
const parser = new Parser();
const extractor = createFieldExtractor(pythonConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.Python;
mockContext.filePath = 'test.py';
it('extracts annotated class fields', () => {
parser.setLanguage(Python);
const tree = parser.parse(`class User:
name: str
email: str
`);
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('User');
expect(result!.fields).toHaveLength(2);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBe('str');
expect(nameField!.visibility).toBe('public');
const emailField = result!.fields.find((f) => f.name === 'email');
expect(emailField).toBeDefined();
expect(emailField!.type).toBe('str');
});
it('detects underscore-based visibility: _protected and __private', () => {
parser.setLanguage(Python);
const tree = parser.parse(`class Settings:
name: str
_internal: int
__secret: str
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(3);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.visibility).toBe('public');
const internalField = result!.fields.find((f) => f.name === '_internal');
expect(internalField).toBeDefined();
expect(internalField!.visibility).toBe('protected');
const secretField = result!.fields.find((f) => f.name === '__secret');
expect(secretField).toBeDefined();
expect(secretField!.visibility).toBe('private');
});
it('does not mark dunder attributes as private', () => {
parser.setLanguage(Python);
// __slots__ starts with __ but also ends with __ so the private check
// (startsWith('__') && !endsWith('__')) is skipped.
// However it still starts with _ so the config classifies it as protected.
const tree = parser.parse(`class Meta:
__slots__: list
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
// __slots__ ends with __ → not private, but still starts with _ → protected
expect(result!.fields[0].visibility).toBe('protected');
});
it('reports isStatic and isReadonly as false', () => {
parser.setLanguage(Python);
const tree = parser.parse(`class A:
x: int
`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields[0].isStatic).toBe(false);
expect(result!.fields[0].isReadonly).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Go config
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — Go', () => {
const parser = new Parser();
const extractor = createFieldExtractor(goConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.Go;
mockContext.filePath = 'test.go';
// Helper: Go AST nests name under type_spec, not type_declaration.
// The generic factory's extract() requires a node with a 'name' field,
// so we walk to type_spec which holds the name.
function findTypeSpec(src: string) {
parser.setLanguage(Go);
const tree = parser.parse(src);
const typeDecl = tree.rootNode.child(0)!;
return { typeDecl, typeSpec: typeDecl.namedChild(0)! };
}
it('recognizes type_declaration via isTypeDeclaration', () => {
const { typeDecl } = findTypeSpec(`type User struct {\n\tName string\n}`);
expect(typeDecl.type).toBe('type_declaration');
expect(extractor.isTypeDeclaration(typeDecl)).toBe(true);
});
it('rejects non-type-declaration nodes', () => {
parser.setLanguage(Go);
const tree = parser.parse(`func main() {}`);
const fnNode = tree.rootNode.child(0);
expect(extractor.isTypeDeclaration(fnNode!)).toBe(false);
expect(extractor.extract(fnNode!, mockContext)).toBeNull();
});
it('detects uppercase-based visibility via extractVisibility on field nodes', () => {
const { typeDecl } = findTypeSpec(
`type Config struct {\n\tHost string\n\tport int\n\tTimeout int\n}`,
);
// Navigate to field_declaration nodes inside the struct
// type_declaration > type_spec > struct_type > field_declaration_list > field_declaration
const typeSpec = typeDecl.namedChild(0)!;
const structType = typeSpec.namedChild(1)!;
const fieldList = structType.namedChild(0)!;
expect(fieldList.type).toBe('field_declaration_list');
const hostNode = fieldList.namedChild(0)!;
const portNode = fieldList.namedChild(1)!;
const timeoutNode = fieldList.namedChild(2)!;
expect(goConfig.extractName(hostNode)).toBe('Host');
expect(goConfig.extractVisibility(hostNode)).toBe('public');
expect(goConfig.extractName(portNode)).toBe('port');
expect(goConfig.extractVisibility(portNode)).toBe('package');
expect(goConfig.extractName(timeoutNode)).toBe('Timeout');
expect(goConfig.extractVisibility(timeoutNode)).toBe('public');
});
it('extracts field types', () => {
const { typeDecl } = findTypeSpec(`type Point struct {\n\tX float64\n\tY float64\n}`);
const typeSpec = typeDecl.namedChild(0)!;
const structType = typeSpec.namedChild(1)!;
const fieldList = structType.namedChild(0)!;
const xNode = fieldList.namedChild(0)!;
expect(goConfig.extractName(xNode)).toBe('X');
expect(goConfig.extractType(xNode)).toBe('float64');
});
it('reports isStatic and isReadonly as false for all fields', () => {
const { typeDecl } = findTypeSpec(`type S struct {\n\tX int\n}`);
const typeSpec = typeDecl.namedChild(0)!;
const structType = typeSpec.namedChild(1)!;
const fieldList = structType.namedChild(0)!;
const fieldNode = fieldList.namedChild(0)!;
expect(goConfig.isStatic(fieldNode)).toBe(false);
expect(goConfig.isReadonly(fieldNode)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// C++ config
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — C++', () => {
const parser = new Parser();
const extractor = createFieldExtractor(cppConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.CPlusPlus;
mockContext.filePath = 'test.cpp';
it('extracts fields from a class with access specifiers', () => {
parser.setLanguage(Cpp);
const tree = parser.parse(`class User {
public:
int id;
std::string name;
private:
std::string password;
};`);
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('User');
expect(result!.fields).toHaveLength(3);
const idField = result!.fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.visibility).toBe('public');
expect(idField!.type).toBe('int');
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.visibility).toBe('public');
const pwField = result!.fields.find((f) => f.name === 'password');
expect(pwField).toBeDefined();
expect(pwField!.visibility).toBe('private');
});
it('uses backward-sibling walk to find access specifier', () => {
parser.setLanguage(Cpp);
// protected section followed by more fields
const tree = parser.parse(`class Base {
protected:
int x;
int y;
public:
int z;
};`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(3);
const xField = result!.fields.find((f) => f.name === 'x');
expect(xField).toBeDefined();
expect(xField!.visibility).toBe('protected');
const yField = result!.fields.find((f) => f.name === 'y');
expect(yField).toBeDefined();
expect(yField!.visibility).toBe('protected');
const zField = result!.fields.find((f) => f.name === 'z');
expect(zField).toBeDefined();
expect(zField!.visibility).toBe('public');
});
it('defaults to private for class without access specifiers', () => {
parser.setLanguage(Cpp);
const tree = parser.parse(`class Secret {
int value;
};`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('value');
expect(result!.fields[0].visibility).toBe('private');
});
it('defaults to public for struct without access specifiers', () => {
parser.setLanguage(Cpp);
const tree = parser.parse(`struct Point {
double x;
double y;
};`);
const structNode = tree.rootNode.child(0);
expect(extractor.isTypeDeclaration(structNode!)).toBe(true);
const result = extractor.extract(structNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
expect(result!.fields[0].visibility).toBe('public');
expect(result!.fields[1].visibility).toBe('public');
});
it('detects static and const fields', () => {
parser.setLanguage(Cpp);
const tree = parser.parse(`class Config {
public:
static int count;
const int MAX_SIZE;
};`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
const countField = result!.fields.find((f) => f.name === 'count');
expect(countField).toBeDefined();
expect(countField!.isStatic).toBe(true);
const maxField = result!.fields.find((f) => f.name === 'MAX_SIZE');
expect(maxField).toBeDefined();
expect(maxField!.isReadonly).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Ruby config
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — Ruby', () => {
const parser = new Parser();
const extractor = createFieldExtractor(rubyConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.Ruby;
mockContext.filePath = 'test.rb';
it('extracts multiple fields from attr_accessor', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class User
attr_accessor :name, :email, :age
end`);
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
expect(extractor.isTypeDeclaration(classNode!)).toBe(true);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('User');
expect(result!.fields).toHaveLength(3);
const names = result!.fields.map((f) => f.name);
expect(names).toContain('name');
expect(names).toContain('email');
expect(names).toContain('age');
// All attr_accessor fields are public
for (const field of result!.fields) {
expect(field.visibility).toBe('public');
expect(field.type).toBeNull();
}
});
it('extracts fields from attr_reader as readonly', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class Config
attr_reader :host, :port
end`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(2);
expect(result!.fields[0].isReadonly).toBe(true);
expect(result!.fields[1].isReadonly).toBe(true);
});
it('extracts fields from attr_writer as non-readonly', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class Settings
attr_writer :theme
end`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].name).toBe('theme');
expect(result!.fields[0].isReadonly).toBe(false);
});
it('handles multiple attr_* calls in the same class', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class Person
attr_accessor :name
attr_reader :id
attr_writer :password
end`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(3);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.isReadonly).toBe(false);
const idField = result!.fields.find((f) => f.name === 'id');
expect(idField).toBeDefined();
expect(idField!.isReadonly).toBe(true);
const pwField = result!.fields.find((f) => f.name === 'password');
expect(pwField).toBeDefined();
expect(pwField!.isReadonly).toBe(false);
});
it('reports type as null (Ruby is dynamically typed)', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class Item
attr_accessor :value
end`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields).toHaveLength(1);
expect(result!.fields[0].type).toBeNull();
});
it('reports isStatic as false', () => {
parser.setLanguage(Ruby);
const tree = parser.parse(`class Demo
attr_accessor :data
end`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.fields[0].isStatic).toBe(false);
});
});
// ---------------------------------------------------------------------------
// C# config — primary constructor fields
// ---------------------------------------------------------------------------
describe('GenericFieldExtractor — C# primary constructor fields', () => {
const parser = new Parser();
const extractor = createFieldExtractor(csharpFieldConfig);
const mockContext = createMockContext();
mockContext.language = SupportedLanguages.CSharp;
mockContext.filePath = 'test.cs';
it('extracts record positional parameters as public readonly fields', () => {
parser.setLanguage(CSharp);
const tree = parser.parse('public record Person(string Name, int Age);');
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
expect(result!.ownerFqn).toBe('Person');
expect(result!.fields).toHaveLength(2);
const nameField = result!.fields.find((f) => f.name === 'Name');
expect(nameField).toBeDefined();
expect(nameField!.type).toBe('string');
expect(nameField!.visibility).toBe('public');
expect(nameField!.isReadonly).toBe(true);
expect(nameField!.isStatic).toBe(false);
const ageField = result!.fields.find((f) => f.name === 'Age');
expect(ageField).toBeDefined();
expect(ageField!.type).toBe('int');
expect(ageField!.visibility).toBe('public');
expect(ageField!.isReadonly).toBe(true);
});
it('extracts class primary constructor parameters as private fields', () => {
parser.setLanguage(CSharp);
const tree = parser.parse(`public class Point(int x, int y) {
public int X => x;
}`);
const classNode = tree.rootNode.child(0);
expect(classNode).toBeDefined();
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
const xField = result!.fields.find((f) => f.name === 'x');
expect(xField).toBeDefined();
expect(xField!.type).toBe('int');
expect(xField!.visibility).toBe('private');
expect(xField!.isReadonly).toBe(false);
});
it('combines body fields with primary constructor fields', () => {
parser.setLanguage(CSharp);
const tree = parser.parse(`public class Service(string name) {
public int Count;
}`);
const classNode = tree.rootNode.child(0);
const result = extractor.extract(classNode!, mockContext);
expect(result).not.toBeNull();
// Body field + primary constructor field
expect(result!.fields.length).toBeGreaterThanOrEqual(2);
const nameField = result!.fields.find((f) => f.name === 'name');
expect(nameField).toBeDefined();
expect(nameField!.visibility).toBe('private');
const countField = result!.fields.find((f) => f.name === 'Count');
expect(countField).toBeDefined();
});
});