fix(objective-c): normalize bare macro markers

This commit is contained in:
ximengkai 2026-09-05 00:10:16 +08:00
parent c80193e1a3
commit 60980b7ced
9 changed files with 260 additions and 4 deletions

View file

@ -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.

View file

@ -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[] => {

View file

@ -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';

View file

@ -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;
}

View file

@ -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

View file

@ -1,5 +1,14 @@
#import <Foundation/Foundation.h>
#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 <NSObject>

View file

@ -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 <NSObject>',
'- (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');

View file

@ -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 <NSObject>',
'- (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 <NSObject>');
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);
});
});

View file

@ -48,6 +48,20 @@ const FIXTURES: Partial<Record<SupportedLanguages, { filePath: string; source: s
filePath: 'meters.dart',
source: ['extension type Meters(int value) {', ' int get raw => 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)