diff --git a/docs/fork/OBJECTIVE_C_PROVIDER.md b/docs/fork/OBJECTIVE_C_PROVIDER.md index 84ad88f30..69f038678 100644 --- a/docs/fork/OBJECTIVE_C_PROVIDER.md +++ b/docs/fork/OBJECTIVE_C_PROVIDER.md @@ -96,12 +96,13 @@ Implemented in the branch: - Vendored `tree-sitter-objc` grammar, registered through the existing Tree-sitter loader. - `.m` and `.mm` language mapping plus content-based `.h` classification so plain C/C++ headers are not unconditionally claimed. - LanguageProvider extraction for classes, protocols, categories, extensions, methods, properties, ivars, C functions, imports, unresolved message evidence, stable Objective-C qualified names, and provider/grammar metadata. +- Length-preserving preprocessing of bare, file-scope all-caps macro markers before Tree-sitter parsing. This recovers declarations after wrappers such as `RCT_EXTERN_C_BEGIN` / `RCT_EXTERN_C_END` without expanding macros or adding framework-specific rules. - ScopeResolver edges for imports, inheritance, protocol conformance, category host membership, implementation evidence, and conservative static message sends. - Persisted query/context support for Objective-C class and method nodes, including implementation evidence via `DECLARES`. - Regression tests for grammar loading, `.h` classification, stable identities, conservative calls, metadata feature mismatch, persisted query/context behavior, and incremental-vs-force parity for Objective-C fixture edits. Known limits of this MVP: -- The first version does not perform full Objective-C runtime dispatch, swizzling, dynamic selector construction, macro expansion, or `id` flow inference. +- The first version does not perform full Objective-C runtime dispatch, swizzling, dynamic selector construction, macro expansion, or `id` flow inference. Bare file-scope marker macros are elided only to preserve parser recovery; their expansion semantics are not interpreted. - Protocol receiver handling records the protocol method and candidate implementation evidence, but candidate implementations are not emitted as certain call edges. - Objective-C++ `.mm` files are parsed with the Objective-C grammar path for this MVP; deep C++ semantic extraction inside Objective-C++ bodies remains outside this provider. diff --git a/gitnexus/src/core/ingestion/languages/objective-c.ts b/gitnexus/src/core/ingestion/languages/objective-c.ts index 731562fd3..1baa5c9ab 100644 --- a/gitnexus/src/core/ingestion/languages/objective-c.ts +++ b/gitnexus/src/core/ingestion/languages/objective-c.ts @@ -11,6 +11,7 @@ import type { ImportResolverFn } from '../import-resolvers/types.js'; import { getLanguageGrammar } from '../../tree-sitter/parser-loader.js'; import { parseSourceSafe } from '../../tree-sitter/safe-parse.js'; import { assertCloneable } from '../workers/clone-safety.js'; +import { preprocessObjectiveCMacroMarkers } from './objective-c/macro-marker-preprocess.js'; import { buildObjectiveCSemanticGraph, buildObjectiveCScopeCaptures, @@ -99,7 +100,13 @@ export function classifyObjectiveCFileContent(filePath: string, sourceText: stri function parseObjectiveCSource(sourceText: string): Parser.Tree { const parser = new Parser(); parser.setLanguage(getLanguageGrammar(SupportedLanguages.ObjectiveC)); - return parseSourceSafe(parser, sourceText, undefined, undefined, 'Objective-C source'); + return parseSourceSafe( + parser, + preprocessObjectiveCMacroMarkers(sourceText), + undefined, + undefined, + 'Objective-C source', + ); } function treeFromCachedOrSource(cachedTree: unknown, sourceText: string): Parser.Tree { @@ -157,6 +164,7 @@ export const objectiveCProvider = defineLanguage({ importResolver: noImportResolution, classifyFileContent: classifyObjectiveCFileContent, shouldClassifyFileContent: isHeaderPath, + preprocessSource: preprocessObjectiveCMacroMarkers, importsExecuteWhereWritten: false, emitScopeCaptures: (sourceText, filePath, cachedTree): readonly CaptureMatch[] => { diff --git a/gitnexus/src/core/ingestion/languages/objective-c/facts.ts b/gitnexus/src/core/ingestion/languages/objective-c/facts.ts index 6358cf4ed..b32621b69 100644 --- a/gitnexus/src/core/ingestion/languages/objective-c/facts.ts +++ b/gitnexus/src/core/ingestion/languages/objective-c/facts.ts @@ -10,7 +10,7 @@ import type { } from '../../language-provider.js'; import { nodeToCapture, walkNamedTree, type SyntaxNode } from '../../utils/ast-helpers.js'; -export const OBJECTIVE_C_PROVIDER_VERSION = '0.1.0'; +export const OBJECTIVE_C_PROVIDER_VERSION = '0.1.1'; export const OBJECTIVE_C_GRAMMAR_PACKAGE = 'tree-sitter-objc'; export const OBJECTIVE_C_GRAMMAR_VERSION = '3.0.2'; diff --git a/gitnexus/src/core/ingestion/languages/objective-c/macro-marker-preprocess.ts b/gitnexus/src/core/ingestion/languages/objective-c/macro-marker-preprocess.ts new file mode 100644 index 000000000..a0d61b34a --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/objective-c/macro-marker-preprocess.ts @@ -0,0 +1,150 @@ +/** + * Normalize bare file-scope Objective-C macro markers before parsing. + * + * Headers commonly use macro pairs such as `RCT_EXTERN_C_BEGIN` and + * `RCT_EXTERN_C_END` around C declarations. tree-sitter-objc does not expand + * those macros; a bare invocation can put the parser into error recovery and + * hide every Objective-C declaration that follows it. These markers do not + * contribute syntax on their own, so we replace only the narrow, generic form + * with spaces before parsing. + * + * This is deliberately not macro expansion or a framework-specific allowlist: + * a candidate must be a whole, all-caps identifier at file scope. Function-like + * macros, directives, statements, strings, and comments remain untouched. + * Replacement preserves UTF-16 length and line endings exactly. Candidates are + * ASCII-only, so their byte offsets are preserved as well. + */ + +interface ScanState { + inBlockComment: boolean; + inPreprocessorDirective: boolean; + quote: '"' | "'" | undefined; + braceDepth: number; +} + +function isAsciiHorizontalWhitespace(code: number): boolean { + return code === 0x20 || code === 0x09; +} + +function isBareMarkerIdentifier(line: string): boolean { + let index = 0; + while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++; + + const identifierStart = index; + let hasUppercaseLetter = false; + while (index < line.length) { + const code = line.charCodeAt(index); + if (code >= 0x41 && code <= 0x5a) { + hasUppercaseLetter = true; + index++; + continue; + } + if ((code >= 0x30 && code <= 0x39) || code === 0x5f) { + index++; + continue; + } + break; + } + if (index === identifierStart || !hasUppercaseLetter) return false; + + while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++; + return index === line.length; +} + +function hasEscapedLineEnding(line: string): boolean { + let trailingBackslashes = 0; + for (let index = line.length - 1; index >= 0 && line.charCodeAt(index) === 0x5c; index--) { + trailingBackslashes++; + } + return trailingBackslashes % 2 === 1; +} + +function startsPreprocessorDirective(line: string): boolean { + let index = 0; + while (index < line.length && isAsciiHorizontalWhitespace(line.charCodeAt(index))) index++; + return line.charCodeAt(index) === 0x23; +} + +function scanLine(line: string, state: ScanState): void { + if (state.inPreprocessorDirective) { + state.inPreprocessorDirective = hasEscapedLineEnding(line); + return; + } + if (startsPreprocessorDirective(line)) { + state.inPreprocessorDirective = hasEscapedLineEnding(line); + return; + } + + for (let index = 0; index < line.length; index++) { + const code = line.charCodeAt(index); + const next = line.charCodeAt(index + 1); + + if (state.inBlockComment) { + if (code === 0x2a && next === 0x2f) { + state.inBlockComment = false; + index++; + } + continue; + } + + if (state.quote !== undefined) { + if (code === 0x5c) { + index++; + } else if (line[index] === state.quote) { + state.quote = undefined; + } + continue; + } + + if (code === 0x2f && next === 0x2f) return; + if (code === 0x2f && next === 0x2a) { + state.inBlockComment = true; + index++; + continue; + } + if (code === 0x22 || code === 0x27) { + state.quote = line[index] as '"' | "'"; + continue; + } + if (code === 0x7b) state.braceDepth++; + else if (code === 0x7d) state.braceDepth = Math.max(0, state.braceDepth - 1); + } + + if (state.quote !== undefined && !hasEscapedLineEnding(line)) state.quote = undefined; +} + +/** + * Elide bare, file-scope macro markers while preserving source positions. + * + * `_filePath` is accepted for the LanguageProvider hook signature. The + * transform is based only on source syntax and deliberately has no framework + * or repository-specific configuration. + */ +export function preprocessObjectiveCMacroMarkers(source: string, _filePath?: string): string { + const state: ScanState = { + inBlockComment: false, + inPreprocessorDirective: false, + quote: undefined, + braceDepth: 0, + }; + const segments = source.split(/(\r\n|\n|\r)/); + let changed = false; + + for (let index = 0; index < segments.length; index += 2) { + const line = segments[index]; + if ( + !state.inBlockComment && + !state.inPreprocessorDirective && + state.quote === undefined && + state.braceDepth === 0 && + isBareMarkerIdentifier(line) + ) { + segments[index] = ' '.repeat(line.length); + changed = true; + continue; + } + scanLine(line, state); + } + + return changed ? segments.join('') : source; +} diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 743b810b4..5614ed206 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -735,7 +735,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // v93: Zig call captures inside a comptime-false branch carry // `@reference.static-gated` (feat/zig-static-gated-edges); the site gains // `staticGated` and the CALLS edge a BOOLEAN column. -const SCHEMA_BUMP = 93; +// v94: Objective-C now elides bare, file-scope macro markers before parsing. +// A warm v93 cache can retain error-recovered trees and provider facts that +// omit Objective-C declarations following markers such as RCT_EXTERN_C_END. +const SCHEMA_BUMP = 94; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/fixtures/objective-c/SYModuleCaller.h b/gitnexus/test/fixtures/objective-c/SYModuleCaller.h index 3fad69ce8..c55ed20db 100644 --- a/gitnexus/test/fixtures/objective-c/SYModuleCaller.h +++ b/gitnexus/test/fixtures/objective-c/SYModuleCaller.h @@ -1,5 +1,14 @@ #import +#define RCT_EXTERN_C_BEGIN +#define RCT_EXTERN_C_END + +RCT_EXTERN_C_BEGIN +typedef struct SYModuleMethodInfo { + const char *const name; +} SYModuleMethodInfo; +RCT_EXTERN_C_END + int SYModuleSupportAdd(int a, int b); @protocol SYModuleRunnable diff --git a/gitnexus/test/integration/objective-c-provider.test.ts b/gitnexus/test/integration/objective-c-provider.test.ts index 7380e8932..7cda338ca 100644 --- a/gitnexus/test/integration/objective-c-provider.test.ts +++ b/gitnexus/test/integration/objective-c-provider.test.ts @@ -8,6 +8,7 @@ import type { GraphNode, RelationshipType } from 'gitnexus-shared'; import { runPipelineFromRepo } from '../../src/core/ingestion/pipeline.js'; import { runFullAnalysis } from '../../src/core/run-analyze.js'; import { LocalBackend } from '../../src/mcp/local/local-backend.js'; +import { classifyObjectiveCFileContent } from '../../src/core/ingestion/languages/objective-c.js'; import type { PipelineResult } from '../../src/types/pipeline.js'; const FIXTURE_DIR = path.resolve( @@ -211,6 +212,25 @@ describe('Objective-C provider integration', () => { }); } + it('classifies a macro-wrapped Objective-C header by declarations after the marker', () => { + expect( + classifyObjectiveCFileContent( + 'RCTBridgeModule.h', + [ + 'RCT_EXTERN_C_BEGIN', + 'typedef struct RCTMethodInfo {', + ' const char *const jsName;', + '} RCTMethodInfo;', + 'RCT_EXTERN_C_END', + '@protocol RCTBridgeModule ', + '- (void)run;', + '@end', + '', + ].join('\n'), + ), + ).toBe(true); + }); + it('indexes Objective-C semantic nodes beyond File nodes', () => { expectNode('objc:protocol:SYModuleRunnable', 'Protocol'); expectNode('objc:class:SYBaseCaller', 'Class'); diff --git a/gitnexus/test/unit/objective-c-macro-marker-preprocess.test.ts b/gitnexus/test/unit/objective-c-macro-marker-preprocess.test.ts new file mode 100644 index 000000000..9a76836ad --- /dev/null +++ b/gitnexus/test/unit/objective-c-macro-marker-preprocess.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { preprocessObjectiveCMacroMarkers } from '../../src/core/ingestion/languages/objective-c/macro-marker-preprocess.js'; + +describe('preprocessObjectiveCMacroMarkers', () => { + it('elides bare file-scope markers and preserves positions', () => { + const source = [ + '#define RCT_EXTERN_C_BEGIN', + '#define RCT_EXTERN_C_END', + 'RCT_EXTERN_C_BEGIN', + 'typedef struct RCTMethodInfo {', + ' const char *const jsName;', + '} RCTMethodInfo;', + 'RCT_EXTERN_C_END', + '@protocol RCTBridgeModule ', + '- (void)run;', + '@end', + '', + ].join('\r\n'); + + const normalized = preprocessObjectiveCMacroMarkers(source, 'RCTBridgeModule.h'); + + expect(normalized).toHaveLength(source.length); + expect(normalized.split('\r\n')).toHaveLength(source.split('\r\n').length); + expect(normalized).toContain('#define RCT_EXTERN_C_BEGIN'); + expect(normalized).toContain('@protocol RCTBridgeModule '); + expect(normalized).toContain(' '.repeat('RCT_EXTERN_C_BEGIN'.length)); + expect(normalized).toContain(' '.repeat('RCT_EXTERN_C_END'.length)); + expect(preprocessObjectiveCMacroMarkers(normalized, 'RCTBridgeModule.h')).toBe(normalized); + }); + + it('leaves non-marker syntax, strings, and comments untouched', () => { + const source = [ + 'void marker(void) {', + ' RCT_EXTERN_C_BEGIN', + '}', + 'RCT_EXTERN_C_END()', + 'RCT_EXTERN_C_END;', + '#define RCT_EXTERN_C_END', + '#define RCT_MARKER_SEQUENCE \\', + 'RCT_EXTERN_C_END', + 'const char *value = "RCT_EXTERN_C_END";', + '// RCT_EXTERN_C_END', + '/*', + 'RCT_EXTERN_C_END', + '*/', + '', + ].join('\n'); + + expect(preprocessObjectiveCMacroMarkers(source, 'Example.m')).toBe(source); + }); +}); diff --git a/gitnexus/test/unit/preprocess-source-parity.test.ts b/gitnexus/test/unit/preprocess-source-parity.test.ts index 643d6cacd..b6881ece9 100644 --- a/gitnexus/test/unit/preprocess-source-parity.test.ts +++ b/gitnexus/test/unit/preprocess-source-parity.test.ts @@ -48,6 +48,20 @@ const FIXTURES: Partial value;', '}', ''].join('\n'), }, + [SupportedLanguages.ObjectiveC]: { + filePath: 'Marker.m', + source: [ + 'RCT_EXTERN_C_BEGIN', + 'typedef struct {', + ' int value;', + '} GNMarker;', + 'RCT_EXTERN_C_END', + '@protocol GNMarkerProtocol', + '- (void)run;', + '@end', + '', + ].join('\n'), + }, }; const languagesWithHook = Object.entries(providers)