Merge branch 'main' into dependabot/npm_and_yarn/gitnexus-web/npm_and_yarn-cfa012f3cb

This commit is contained in:
Gergő Magyar 2026-05-09 13:16:05 +01:00 • committed by GitHub
commit 15077da07b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 600 additions and 10 deletions

View file

@ -769,9 +769,10 @@ export const processCalls = async (
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
continue;
@ -3280,9 +3281,10 @@ export const extractFetchCallsFromFiles = async (
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
continue;

View file

@ -0,0 +1,265 @@
/**
* Unreal Engine reflection-macro preprocessor for C++ source.
*
* Tree-sitter does not expand C preprocessor macros, so Unreal's reflection
* markers (`UCLASS(...)`, `UFUNCTION(...)`, `MODULENAME_API`, ...) are parsed
* verbatim. The result is mis-parsed declarations: in `class BRAWLUI_API
* UMyClass : public UObject`, tree-sitter-cpp captures `BRAWLUI_API` as the
* class name and the rest of the declaration becomes structurally wrong.
*
* This module elides those macros from the source text BEFORE tree-sitter
* parses it. Replacement 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.
*
* Pure function — no tree-sitter dependency, safe for worker threads.
*/
/**
* Strong UE markers — reflection macros that only Unreal Engine projects use.
* Presence of one of these is sufficient evidence that the file is a UE source
* and that `MODULENAME_API` tokens in it are intended as export macros.
*
* Importantly, `_API` tokens are NOT in this guard — `REST_API`, `HTTP_API`,
* `MY_LIB_API` and similar identifiers appear in plenty of non-UE C++ codebases
* as constants/enums/parameter names. We must not erase them just because the
* file mentions an `_API` token.
*/
const HAS_UE_HINT =
/\b(?:UCLASS|UFUNCTION|UPROPERTY|USTRUCT|UENUM|UINTERFACE|GENERATED_BODY|GENERATED_[A-Z_]+_BODY|UE_DEPRECATED|DECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE)/;
const SIMPLE_MACROS_NO_ARGS: readonly string[] = [
'GENERATED_BODY',
'GENERATED_UCLASS_BODY',
'GENERATED_USTRUCT_BODY',
'GENERATED_UINTERFACE_BODY',
'GENERATED_IINTERFACE_BODY',
'DECLARE_CLASS',
'GENERATED_BODY_LEGACY',
];
const PARENTHESIZED_MACROS: readonly string[] = [
'UCLASS',
'UFUNCTION',
'UPROPERTY',
'USTRUCT',
'UENUM',
'UINTERFACE',
'UMETA',
'UE_DEPRECATED',
];
const DELEGATE_MACRO_RE =
/\bDECLARE_(?:DYNAMIC_)?(?:MULTICAST_)?DELEGATE(?:_(?:RetVal_OneParam|RetVal_TwoParams|RetVal_ThreeParams|RetVal_FourParams|RetVal_FiveParams|RetVal_SixParams|RetVal_SevenParams|RetVal_EightParams|RetVal_NineParams|RetVal|OneParam|TwoParams|ThreeParams|FourParams|FiveParams|SixParams|SevenParams|EightParams|NineParams|TenParams))?(?=\s*\()/g;
/**
* Module export tokens like `BRAWLUI_API`, `ENGINE_API`, `COREUOBJECT_API`.
* Pattern: ALL_CAPS identifier ending in `_API`. The leading word boundary
* (`\b`) prevents matching mid-identifier.
*/
const API_MACRO_RE = /\b[A-Z][A-Z0-9_]*_API\b/g;
/** Replace `[start, end)` of `chars` with spaces, preserving newlines. */
function eraseRange(chars: string[], start: number, end: number): void {
for (let i = start; i < end; i++) {
if (chars[i] !== '\n' && chars[i] !== '\r') {
chars[i] = ' ';
}
}
}
/**
* Find the matching close paren for an opening paren at index `openIdx`.
* Returns the index of `)` (inclusive end), or -1 if unbalanced.
*
* Handles nested parens and string/char literals so commas/parens inside
* strings don't throw off the match. Does not attempt to handle raw string
* literals (`R"(...)"`); UE reflection-macro arguments do not use them in
* practice.
*/
function findMatchingParen(source: string, openIdx: number): number {
if (source.charCodeAt(openIdx) !== 0x28) return -1;
let depth = 1;
let i = openIdx + 1;
const len = source.length;
while (i < len && depth > 0) {
const ch = source.charCodeAt(i);
// String literal
if (ch === 0x22) {
i++;
while (i < len) {
const c = source.charCodeAt(i);
if (c === 0x5c) {
i += 2;
continue;
}
if (c === 0x22) {
i++;
break;
}
i++;
}
continue;
}
// Char literal
if (ch === 0x27) {
i++;
while (i < len) {
const c = source.charCodeAt(i);
if (c === 0x5c) {
i += 2;
continue;
}
if (c === 0x27) {
i++;
break;
}
i++;
}
continue;
}
// Line comment
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2f) {
while (i < len && source.charCodeAt(i) !== 0x0a) i++;
continue;
}
// Block comment
if (ch === 0x2f && source.charCodeAt(i + 1) === 0x2a) {
i += 2;
while (i < len) {
if (source.charCodeAt(i) === 0x2a && source.charCodeAt(i + 1) === 0x2f) {
i += 2;
break;
}
i++;
}
continue;
}
if (ch === 0x28) depth++;
else if (ch === 0x29) {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}
/** Match a whole-word identifier at `idx`. Returns the byte after the identifier, or -1 on miss. */
function matchIdentifierAt(source: string, idx: number, name: string): number {
if (idx > 0) {
const prev = source.charCodeAt(idx - 1);
if (
(prev >= 0x30 && prev <= 0x39) ||
(prev >= 0x41 && prev <= 0x5a) ||
(prev >= 0x61 && prev <= 0x7a) ||
prev === 0x5f
) {
return -1;
}
}
for (let k = 0; k < name.length; k++) {
if (source.charCodeAt(idx + k) !== name.charCodeAt(k)) return -1;
}
const after = idx + name.length;
if (after < source.length) {
const next = source.charCodeAt(after);
if (
(next >= 0x30 && next <= 0x39) ||
(next >= 0x41 && next <= 0x5a) ||
(next >= 0x61 && next <= 0x7a) ||
next === 0x5f
) {
return -1;
}
}
return after;
}
/** Skip ASCII whitespace forward from `idx`. Returns the next non-whitespace byte index. */
function skipWhitespace(source: string, idx: number): number {
const len = source.length;
while (idx < len) {
const ch = source.charCodeAt(idx);
if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) {
idx++;
continue;
}
break;
}
return idx;
}
/**
* Strip Unreal Engine reflection macros from C++ source, length-preserving.
*
* Returns the original string unchanged if no strong UE marker is detected,
* so non-UE C++ files (including ones that contain `*_API`-suffixed
* identifiers like `REST_API` or `HTTP_API`) incur only a single regex test.
*
* The `_filePath` parameter is part of the `LanguageProvider.preprocessSource`
* contract but is unused — UE detection is purely content-based. Accepted and
* ignored here so the function matches the hook signature exactly.
*/
export function stripUeMacros(source: string, _filePath?: string): string {
if (!HAS_UE_HINT.test(source)) return source;
const chars: string[] = source.split('');
for (const macro of PARENTHESIZED_MACROS) {
let searchFrom = 0;
while (true) {
const hit = source.indexOf(macro, searchFrom);
if (hit < 0) break;
searchFrom = hit + 1;
const after = matchIdentifierAt(source, hit, macro);
if (after < 0) continue;
const parenIdx = skipWhitespace(source, after);
if (source.charCodeAt(parenIdx) !== 0x28) continue;
const close = findMatchingParen(source, parenIdx);
if (close < 0) continue;
eraseRange(chars, hit, close + 1);
}
}
for (const macro of SIMPLE_MACROS_NO_ARGS) {
let searchFrom = 0;
while (true) {
const hit = source.indexOf(macro, searchFrom);
if (hit < 0) break;
searchFrom = hit + 1;
const after = matchIdentifierAt(source, hit, macro);
if (after < 0) continue;
const parenIdx = skipWhitespace(source, after);
if (source.charCodeAt(parenIdx) === 0x28) {
const close = findMatchingParen(source, parenIdx);
if (close < 0) continue;
eraseRange(chars, hit, close + 1);
} else {
eraseRange(chars, hit, after);
}
}
}
for (const re of [DELEGATE_MACRO_RE, API_MACRO_RE]) {
re.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = re.exec(source)) !== null) {
const start = match.index;
let end = start + match[0].length;
if (re === DELEGATE_MACRO_RE) {
const parenIdx = skipWhitespace(source, end);
if (source.charCodeAt(parenIdx) === 0x28) {
const close = findMatchingParen(source, parenIdx);
if (close >= 0) end = close + 1;
}
}
eraseRange(chars, start, end);
}
}
return chars.join('');
}

View file

@ -219,9 +219,13 @@ export const processHeritage = async (
let tree = astCache.get(file.path);
if (!tree) {
// Use larger bufferSize for files > 32KB
// Per-language source preprocessor (length-preserving, e.g. UE macro
// stripping for C++). MUST mirror parsing-processor on cache miss so
// re-parses see the same input as the cached AST.
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
// Skip files that can't be parsed
@ -413,9 +417,10 @@ export async function extractExtractedHeritageFromFiles(
let tree = astCache.get(file.path);
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch {
continue;

View file

@ -305,9 +305,10 @@ export const processImports = async (
let wasReparsed = false;
if (!tree) {
const parseContent = provider.preprocessSource?.(file.content, file.path) ?? file.content;
try {
tree = parser.parse(file.content, undefined, {
bufferSize: getTreeSitterBufferSize(file.content),
tree = parser.parse(parseContent, undefined, {
bufferSize: getTreeSitterBufferSize(parseContent),
});
} catch (parseError) {
continue;

View file

@ -116,6 +116,39 @@ interface LanguageProviderConfig {
* Required for tree-sitter languages; empty string for standalone processors. */
readonly treeSitterQueries: string;
/**
* Optional source-text transform that runs **before** tree-sitter parses the file.
*
* Used to elide language constructs that confuse the grammar without affecting
* source-position fidelity — e.g., Unreal Engine reflection macros (`UCLASS`,
* `UFUNCTION`, `MODULENAME_API`) in C++ headers that prevent the parser from
* recognising class/function names correctly.
*
* **Length / position preservation:** the returned string MUST have the same
* JavaScript `.length` as the input AND preserve every newline (`\n`/`\r`)
* position byte-for-byte. Implementations replace elided characters with
* ASCII spaces while leaving newlines untouched. With this contract:
*
* - tree-sitter's reported `startPosition.row`/`startPosition.column`
* match the original file exactly (line/column come from newline counts)
* - `startIndex`/`endIndex` byte offsets match the original file exactly
* **when the elided range is pure ASCII** (UTF-16 `.length` equals UTF-8
* byte length only for ASCII).
*
* Implementations targeting languages where elided ranges may contain
* non-ASCII content must therefore preserve byte length, not just `.length`,
* if downstream code uses `startIndex` to slice the original UTF-8 bytes.
* The current C++ UE-macro preprocessor relies on the practical fact that
* UE reflection macros and module-export tokens are ASCII-only.
*
* Must be a pure function — same input always yields the same output. Called
* once per file, on every code path that re-parses (parsing-processor, import
* processor, heritage processor, call processor, parse worker).
*
* Default: undefined (no preprocessing — `file.content` is parsed verbatim).
*/
readonly preprocessSource?: (sourceText: string, filePath: string) => string;
// ── Core (required) ───────────────────────────────────────────────
/** Type extraction: declarations, initializers, for-loop bindings */
readonly typeConfig: LanguageTypeConfig;

View file

@ -45,6 +45,7 @@ import { cVariableConfig, cppVariableConfig } from '../variable-extractors/confi
import { createCallExtractor } from '../call-extractors/generic.js';
import { cCallConfig, cppCallConfig } from '../call-extractors/configs/c-cpp.js';
import { createHeritageExtractor } from '../heritage-extractors/generic.js';
import { stripUeMacros } from '../cpp-ue-preprocessor.js';
const C_BUILT_INS: ReadonlySet<string> = new Set([
'printf',
@ -410,6 +411,7 @@ export const cppProvider = defineLanguage({
},
] satisfies AstFrameworkPatternConfig[],
treeSitterQueries: CPP_QUERIES,
preprocessSource: stripUeMacros,
typeConfig: cCppConfig,
exportChecker: cCppExportChecker,
importResolver: createImportResolver(cppImportConfig),

View file

@ -371,6 +371,11 @@ const processParsingSequential = async (
isVueSetup = extracted.isSetup;
}
// Per-language source-text transform (e.g., UE macro stripping for C++).
// Length-preserving — see LanguageProvider.preprocessSource contract.
parseContent =
getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent;
try {
await loadLanguage(language, file.path);
} catch {

View file

@ -1407,6 +1407,11 @@ const processFileGroup = (
isVueSetup = extracted.isSetup;
}
// Per-language source-text transform (e.g., UE macro stripping for C++).
// Length-preserving — see LanguageProvider.preprocessSource contract.
parseContent =
getProvider(language).preprocessSource?.(parseContent, file.path) ?? parseContent;
clearCaches(); // Reset memoization before each new file
let tree;

View file

@ -0,0 +1,272 @@
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)
});
});