diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index 6c59578b0..bf0206057 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts b/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts new file mode 100644 index 000000000..baa5d17ff --- /dev/null +++ b/gitnexus/src/core/ingestion/cpp-ue-preprocessor.ts @@ -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(''); +} diff --git a/gitnexus/src/core/ingestion/heritage-processor.ts b/gitnexus/src/core/ingestion/heritage-processor.ts index 2c973ad8e..f8628e651 100644 --- a/gitnexus/src/core/ingestion/heritage-processor.ts +++ b/gitnexus/src/core/ingestion/heritage-processor.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 03cbed5b7..6f0b40b6f 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index 12ce0839b..e139cf5f3 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -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; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index a5b3e5729..693e8cec2 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -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 = 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), diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 8803ec023..98036fbe8 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -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 { diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 5c6712562..4442b5a65 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -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; diff --git a/gitnexus/test/unit/cpp-ue-preprocessor.test.ts b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts new file mode 100644 index 000000000..087c5ba2a --- /dev/null +++ b/gitnexus/test/unit/cpp-ue-preprocessor.test.ts @@ -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 \nstd::vector 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) + }); +});