GitNexus/gitnexus/test/unit/cpp-ue-preprocessor.test.ts
Kareem 3daf8c9984
feat(extractors): strip Unreal Engine reflection macros before C++ parsing (#1439)
* feat(extractors): strip Unreal Engine reflection macros before C++ parsing

Tree-sitter does not expand C preprocessor macros, so Unreal Engine reflection markers (UCLASS, UFUNCTION, UPROPERTY, MODULENAME_API, GENERATED_BODY, ...) are parsed verbatim. The result is mis-parsed UE class/function declarations: in 'class BRAWLUI_API UMyClass : public UObject', tree-sitter-cpp captures BRAWLUI_API as the class name, leaving the actual class without an entry in the graph.

This patch adds an optional 'preprocessSource' hook to LanguageProvider and implements it for C++ via a new 'stripUeMacros' module. The transform is length-preserving (each elided byte becomes a space, newlines preserved) so byte offsets and line/column positions tree-sitter reports remain identical to the original file -- symbol locations in the graph stay accurate.

A cheap detection guard short-circuits files that don't look like UE sources, so non-UE C++ codebases pay no cost (single regex test then bail).

27 unit tests cover the detection guard, length preservation across multiple UE samples, macro removal for UCLASS/UFUNCTION/UPROPERTY/USTRUCT/GENERATED_BODY/MODULE_API/DECLARE_*_DELEGATE/UE_DEPRECATED, false-positive guards (substring matches, balanced parens inside string literals, Qt macros left alone), and class-name extraction sanity. Full unit suite still passes (5337 tests, 0 regressions). Verified end-to-end against an Unreal Engine 5.7 game project (Brawl).

* fix(extractors): address PR review findings on UE macro preprocessor

Resolves three blocking issues raised by automated review:

1. Prettier format: ran prettier --write on call-processor.ts, heritage-processor.ts, import-processor.ts (the three sites where the cache-miss reparse hook insertion landed unformatted).

2. Byte-length contract narrowed: language-provider.ts docblock now states the contract precisely (UTF-16 .length + newline-position preservation, not UTF-8 byte length). Notes that startIndex byte offsets only match the original file when the elided range is pure ASCII -- which is the practical UE case (reflection macros and module-export tokens are ASCII-only).

3. Tree-sitter extraction tests added: new end-to-end tests parse the preprocessed source with tree-sitter-cpp and assert the captured class/struct name is the real UClass identifier (UMyClass, FMyData), never the MODULE_API export macro. Also asserts source positions (startPosition.row) survive the transform.

Plus one moderate fix:

4. _API stripping is now scoped to UE files only. The HAS_UE_HINT guard previously included [A-Z]_API tokens, which would fire on non-UE codebases that use REST_API / HTTP_API / MY_LIB_API as constants or enum values, silently erasing them. The guard now requires a strong UE marker (UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|UE_DEPRECATED|DECLARE_*_DELEGATE) to be present before any stripping runs. Two new tests confirm REST_API and DECLARE_HANDLER style identifiers in non-UE files are left untouched.

Plus one minor fix:

5. stripUeMacros signature now accepts (source, _filePath?) to match the LanguageProvider.preprocessSource hook contract exactly. The filePath argument is unused; UE detection is purely content-based.

Verification: 34/34 preprocessor tests pass (was 27, +7 new for non-ASCII preservation, REST_API safety, tree-sitter extraction, struct extraction, source position preservation). Full unit suite 5349 pass, 0 regressions. Typecheck clean. Prettier --check clean on all 9 changed files.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-05-09 12:28:32 +01:00

272 lines
11 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import CPP from 'tree-sitter-cpp';
import { stripUeMacros } from '../../src/core/ingestion/cpp-ue-preprocessor.js';
describe('stripUeMacros — detection guard', () => {
it('returns input unchanged when no UE markers are present', () => {
const src = `class Plain {\npublic:\n int Get() const;\n};`;
expect(stripUeMacros(src)).toBe(src);
});
it('returns input unchanged for STL-style code', () => {
const src = `#include <vector>\nstd::vector<int> v;`;
expect(stripUeMacros(src)).toBe(src);
});
});
describe('stripUeMacros — length preservation', () => {
const ueSamples: string[] = [
`UCLASS()\nclass BRAWLUI_API UMyClass : public UObject { GENERATED_BODY() public: UFUNCTION() void Run(); };`,
`UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Combat") int32 Health;`,
`USTRUCT(BlueprintType)\nstruct ENGINE_API FMyData { GENERATED_BODY() float Value; };`,
`DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FMyDelegate, int32, A, FString, B);`,
`UE_DEPRECATED(5.0, "Use NewThing instead") void OldThing();`,
];
for (const src of ueSamples) {
it(`preserves byte length: ${src.slice(0, 40).replace(/\n/g, '\\n')}`, () => {
const out = stripUeMacros(src);
expect(out.length).toBe(src.length);
});
it(`preserves newline positions: ${src.slice(0, 40).replace(/\n/g, '\\n')}`, () => {
const out = stripUeMacros(src);
const inputNewlines: number[] = [];
const outputNewlines: number[] = [];
for (let i = 0; i < src.length; i++) {
if (src.charCodeAt(i) === 0x0a) inputNewlines.push(i);
if (out.charCodeAt(i) === 0x0a) outputNewlines.push(i);
}
expect(outputNewlines).toEqual(inputNewlines);
});
}
});
describe('stripUeMacros — macro removal', () => {
it('elides UCLASS(...) with arguments', () => {
const src = `UCLASS(BlueprintType, Category="Foo")\nclass UFoo {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('UCLASS');
expect(out).not.toContain('BlueprintType');
expect(out).toContain('class UFoo {};');
});
it('elides UCLASS() with empty parens', () => {
const src = `UCLASS()\nclass UBar {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('UCLASS');
expect(out).toContain('class UBar {};');
});
it('elides MODULE_API export macros (BRAWLUI_API style) when paired with a UE marker', () => {
const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('BRAWLUI_API');
expect(out).toContain('class');
expect(out).toContain('UMyClass');
expect(out).toContain('public UObject');
});
it('elides multiple distinct *_API tokens in same file when UE marker is present', () => {
const src = `UCLASS()\nclass CORE_API A {};\nUCLASS()\nclass UMG_API B : public A {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('CORE_API');
expect(out).not.toContain('UMG_API');
expect(out).toContain('class');
expect(out).toContain('A {};');
});
it('elides GENERATED_BODY() inside class body', () => {
const src = `class UThing { GENERATED_BODY() public: void Foo(); };`;
const out = stripUeMacros(src);
expect(out).not.toContain('GENERATED_BODY');
expect(out).toContain('public:');
expect(out).toContain('void Foo();');
});
it('elides UFUNCTION(...) before method declarations', () => {
const src = `class X { UFUNCTION(BlueprintCallable, Server, Reliable) void DoThing(); };`;
const out = stripUeMacros(src);
expect(out).not.toContain('UFUNCTION');
expect(out).not.toContain('BlueprintCallable');
expect(out).toContain('void DoThing();');
});
it('elides UPROPERTY(...) before field declarations', () => {
const src = `class X { UPROPERTY(EditAnywhere) int32 Health; };`;
const out = stripUeMacros(src);
expect(out).not.toContain('UPROPERTY');
expect(out).not.toContain('EditAnywhere');
expect(out).toContain('int32 Health;');
});
it('elides DECLARE_DYNAMIC_MULTICAST_DELEGATE_*Params(...)', () => {
const src = `DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FMyDelegate, int32, Value);\nclass X {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('DECLARE_DYNAMIC_MULTICAST_DELEGATE');
expect(out).not.toContain('FMyDelegate');
expect(out).toContain('class X {};');
});
it('elides UE_DEPRECATED(...) before function declarations', () => {
const src = `UE_DEPRECATED(5.1, "Reason") void Old();`;
const out = stripUeMacros(src);
expect(out).not.toContain('UE_DEPRECATED');
expect(out).not.toContain('5.1');
expect(out).toContain('void Old();');
});
});
describe('stripUeMacros — non-UE files left alone', () => {
it('does NOT strip standalone *_API identifiers when no UE marker is present', () => {
const src = `enum class Status { REST_API = 1, HTTP_API = 2, MY_LIB_API = 3 };\nvoid handle(REST_API status);`;
expect(stripUeMacros(src)).toBe(src);
});
it('does NOT strip _API tokens in a file that only mentions DECLARE_DELEGATE-like macros from non-UE codebases', () => {
const src = `// Custom delegate framework, not UE\n#define DECLARE_HANDLER(x) void x()\nDECLARE_HANDLER(MyHandler);\nint REST_API = 0;`;
expect(stripUeMacros(src)).toBe(src);
});
});
describe('stripUeMacros — non-ASCII content preservation', () => {
it('leaves non-ASCII content outside elided ranges intact and at the same .length offset', () => {
const src = `// Comment with non-ASCII: café résumé naïve\nUCLASS()\nclass UMyClass : public UObject\n{\n GENERATED_BODY()\n // Trailing: 日本語 αβγ\n};`;
const out = stripUeMacros(src);
expect(out.length).toBe(src.length);
expect(out).toContain('café résumé naïve');
expect(out).toContain('日本語 αβγ');
expect(out).toContain('class UMyClass : public UObject');
expect(out).not.toContain('UCLASS');
expect(out).not.toContain('GENERATED_BODY');
});
it('preserves newline positions when the file contains non-ASCII characters', () => {
const src = `// café\nUPROPERTY()\nint32 Health;\n// résumé\nUFUNCTION()\nvoid Run();`;
const out = stripUeMacros(src);
const inputNewlines: number[] = [];
const outputNewlines: number[] = [];
for (let i = 0; i < src.length; i++) {
if (src.charCodeAt(i) === 0x0a) inputNewlines.push(i);
if (out.charCodeAt(i) === 0x0a) outputNewlines.push(i);
}
expect(outputNewlines).toEqual(inputNewlines);
});
});
describe('stripUeMacros — false-positive guards', () => {
it('does NOT strip identifiers that merely contain UCLASS as a substring', () => {
const src = `void NotUCLASSAtAll(); int MyUCLASS = 0;`;
const out = stripUeMacros(src);
expect(out).toBe(src);
});
it('does NOT strip _API substrings inside larger identifiers', () => {
const src = `class MY_APIName {};\nint not_my_API_thing = 0;`;
const out = stripUeMacros(src);
expect(out).toContain('MY_APIName');
expect(out).toContain('not_my_API_thing');
});
it('does not eat parens balanced inside string literals', () => {
const src = `UFUNCTION(meta=(DisplayName="Foo (Bar)")) void Z();`;
const out = stripUeMacros(src);
expect(out).not.toContain('UFUNCTION');
expect(out).not.toContain('DisplayName');
expect(out).toContain('void Z();');
});
it('handles UCLASS with deeply nested parens in arguments', () => {
const src = `UCLASS(meta=(Categories=("A.B", "C.D")), Within=Foo) class UDeep {};`;
const out = stripUeMacros(src);
expect(out).not.toContain('UCLASS');
expect(out).not.toContain('Categories');
expect(out).toContain('class UDeep {};');
});
it('leaves Qt macros alone (only UE markers stripped)', () => {
const src = `class QFoo { Q_OBJECT public: void Bar(); };`;
const out = stripUeMacros(src);
expect(out).toContain('Q_OBJECT');
});
});
describe('stripUeMacros — class-name extraction sanity', () => {
it('after stripping, "class UMyClass" appears immediately after "class "', () => {
const src = `UCLASS(BlueprintType)\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n};`;
const out = stripUeMacros(src);
const classIdx = out.indexOf('class ');
expect(classIdx).toBeGreaterThanOrEqual(0);
const tail = out.slice(classIdx + 'class '.length).trimStart();
expect(tail.startsWith('UMyClass')).toBe(true);
});
});
describe('stripUeMacros — tree-sitter extraction (end-to-end)', () => {
/**
* Walk the parse tree and return the captured class name(s). Works against
* the actual tree-sitter-cpp grammar so this is a true integration check
* for the core PR claim: the indexer now sees `UMyClass`, not `BRAWLUI_API`.
*/
function extractClassNames(source: string): string[] {
const parser = new Parser();
parser.setLanguage(CPP as unknown as Parser.Language);
const tree = parser.parse(source);
const names: string[] = [];
const stack: Parser.SyntaxNode[] = [tree.rootNode];
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'class_specifier' || node.type === 'struct_specifier') {
const nameNode = node.childForFieldName('name');
if (nameNode) names.push(nameNode.text);
}
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
return names;
}
it('tree-sitter-cpp captures UMyClass as the class name (not BRAWLUI_API)', () => {
const src = `UCLASS(BlueprintType)\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n UFUNCTION()\n void Run();\n};`;
const out = stripUeMacros(src);
const names = extractClassNames(out);
expect(names).toContain('UMyClass');
expect(names).not.toContain('BRAWLUI_API');
});
it('tree-sitter-cpp captures struct name correctly through USTRUCT + MODULE_API', () => {
const src = `USTRUCT(BlueprintType)\nstruct ENGINE_API FMyData : public FBase\n{\n GENERATED_BODY()\n float Value;\n};`;
const out = stripUeMacros(src);
const names = extractClassNames(out);
expect(names).toContain('FMyData');
expect(names).not.toContain('ENGINE_API');
});
it('tree-sitter-cpp source positions are preserved across stripping (line numbers match)', () => {
const src = `UCLASS()\nclass BRAWLUI_API UMyClass : public UObject\n{\n GENERATED_BODY()\n public:\n void Run();\n};`;
const out = stripUeMacros(src);
const parser = new Parser();
parser.setLanguage(CPP as unknown as Parser.Language);
const tree = parser.parse(out);
const stack: Parser.SyntaxNode[] = [tree.rootNode];
let runLine: number | undefined;
while (stack.length > 0) {
const node = stack.pop()!;
if (node.type === 'function_declarator') {
const declarator = node.childForFieldName('declarator');
if (declarator?.text === 'Run') {
runLine = node.startPosition.row;
break;
}
}
for (let i = node.namedChildCount - 1; i >= 0; i--) {
const child = node.namedChild(i);
if (child) stack.push(child);
}
}
expect(runLine).toBe(5); // 0-indexed: "void Run();" is on line 6 (index 5)
});
});