GitNexus/gitnexus/test/unit/scope-resolution/objective-c/objc-workspace.test.ts
mengkaka 4154b63131
feat(indexing): add Objective-C semantic indexing support (#3179)
* docs: add Objective-C fork provider notes

* feat(objective-c): add deterministic provider and grammar

* feat(objective-c): finalize provider MVP

* fix(objective-c): harden provider integration

* fix(objective-c): normalize bare macro markers

* docs(objective-c): integrate provider documentation

* fix(objective-c): harden resolution and header classification

* fix(objective-c): complete provider follow-ups

* fix: address Objective-C review follow-ups

* chore: format Objective-C grammar sources

* fix(objective-c): harden review follow-ups

* Address PR review feedback (#3179)

Keep Objective-C chunking and macro recovery aligned with the grammar, and stop Community MEMBER_OF edges from leaking into symbol context.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address follow-up review on ObjC chunking and language fallback.

Keep preprocessor directive text from changing file-scope brace depth, group real ivar nodes, skip header modifiers, and restore Rakefile/Gemfile detection through getLanguageFromFilename.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Parse Objective-C headers with the objc grammar in embeddings.

ensureAndParse and structural extraction now use the same content classifier as ingest, including method snippets from .h files, so Protocol/Category/Class chunks are not re-parsed as C++.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Keep file-scope macro elision off C line splices and @interface/@protocol/@implementation bodies, and attach ivar attributes to the following instance variable when chunking.

Co-authored-by: Cursor <cursoragent@cursor.com>

* chore(bench): rebaseline Objective-C CSV emit

* feat(objective-c): add workspace resolution and linear emit benches

Plain .h files are classified as C++, so the ObjC pass could not
resolve #import of those headers. Load a C/C#-style workspace once
per pass, and keep protocol-candidate USES linear.

Refs #3179

Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

- Compare LadybugDB labels() as a scalar when excluding Community MEMBER_OF edges.
- Walk superclass members, skip file-static C sibling defs, and ignore comments in ObjC header/macro scans.

Note: pre-existing failure in objective-c-provider integration (worker-pool ready timeout) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit Objective-C declaration captures so compilation-unit siblings can share
header/implementation bindings, and keep class vs protocol visibility groups
distinct.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

* Address PR review feedback (#3179)

Emit every comma-separated property/ivar declarator, and count @interface
after a multiline block comment closes so in-declaration macros stay intact.

Note: pre-existing failure in worker-pool startup (GITNEXUS_WORKER_READY_TIMEOUT_MS) not addressed by this PR.
Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: ximengkai <ximengkai@soyoung.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 09:40:21 +00:00

423 lines
15 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'fs';
import { join } from 'path';
import type { ParsedFile, SymbolDefinition } from 'gitnexus-shared';
import {
getObjectiveCFileFacts,
objcFunctionQualifiedName,
setObjectiveCFileFacts,
type ObjCFileFacts,
} from '../../../../src/core/ingestion/languages/objective-c/facts.js';
import { populateObjectiveCCompilationUnitSiblings } from '../../../../src/core/ingestion/languages/objective-c/compilation-unit-siblings.js';
import { resolveObjectiveCImportTarget } from '../../../../src/core/ingestion/languages/objective-c/import-target.js';
import {
loadObjectiveCResolutionConfig,
parseModuleMap,
parseXcconfigSearchPaths,
type ObjectiveCResolutionConfig,
} from '../../../../src/core/ingestion/languages/objective-c/resolution-config.js';
import { objectiveCScopeResolver } from '../../../../src/core/ingestion/languages/objective-c/scope-resolver.js';
import type { ScopeResolutionIndexes } from '../../../../src/core/ingestion/model/scope-resolution-indexes.js';
const TMP = join(__dirname, '__objc_workspace_tmp__');
function touch(rel: string, contents = ''): void {
const full = join(TMP, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, contents);
}
function config(partial: Partial<ObjectiveCResolutionConfig>): ObjectiveCResolutionConfig {
return {
headers: partial.headers ?? new Set(),
headerSearchPaths: partial.headerSearchPaths ?? [],
userHeaderSearchPaths: partial.userHeaderSearchPaths ?? [],
frameworks: partial.frameworks ?? new Map(),
modules: partial.modules ?? new Map(),
};
}
function staleFacts(filePath: string): ObjCFileFacts {
return {
providerVersion: 'test',
grammarPackage: 'test',
grammarVersion: 'test',
filePath,
containers: [],
methods: [],
members: [],
functions: [],
imports: [],
messages: [],
unresolvedMessages: [],
};
}
beforeEach(() => {
mkdirSync(TMP, { recursive: true });
});
afterEach(() => {
rmSync(TMP, { recursive: true, force: true });
});
describe('Objective-C workspace scan', () => {
it('finds source headers and skips build / Cocoa output trees', () => {
touch('Headers/Widget.h');
touch('src/foo.h');
touch('DerivedData/ModuleCache/gen.h');
touch('Pods/AFNetworking/AFNetworking.h');
touch('build/config.h');
const scanned = loadObjectiveCResolutionConfig(TMP);
expect(scanned.headers).toContain('Headers/Widget.h');
expect(scanned.headers).toContain('src/foo.h');
expect(scanned.headers).not.toContain('DerivedData/ModuleCache/gen.h');
expect(scanned.headers).not.toContain('Pods/AFNetworking/AFNetworking.h');
expect(scanned.headers).not.toContain('build/config.h');
expect(scanned.userHeaderSearchPaths).toContain('Headers');
});
it('records in-repo frameworks and module maps', () => {
touch('MyKit.framework/Headers/Widget.h', '@interface Widget\n@end\n');
touch(
'MyKit.framework/Modules/module.modulemap',
'framework module MyKit {\n umbrella header "Widget.h"\n export *\n}\n',
);
const scanned = loadObjectiveCResolutionConfig(TMP);
expect(scanned.frameworks.get('MyKit')).toBe('MyKit.framework/Headers');
expect(scanned.modules.get('MyKit')).toBe('MyKit.framework/Headers/Widget.h');
});
it('reads HEADER_SEARCH_PATHS from xcconfig and ignores SDK roots', () => {
touch(
'App.xcconfig',
[
'HEADER_SEARCH_PATHS = $(SRCROOT)/Vendor/Headers $(SDKROOT)/usr/include $(inherited)',
'USER_HEADER_SEARCH_PATHS = "Private Headers"',
'',
].join('\n'),
);
const scanned = loadObjectiveCResolutionConfig(TMP);
expect(scanned.headerSearchPaths).toEqual(['Vendor/Headers']);
expect(scanned.userHeaderSearchPaths).toEqual(['Private Headers']);
});
it('clears stale file facts at the start of a workspace pass', () => {
setObjectiveCFileFacts(staleFacts('stale.m'));
expect(getObjectiveCFileFacts('stale.m')).toBeDefined();
objectiveCScopeResolver.loadResolutionConfig?.(TMP);
expect(getObjectiveCFileFacts('stale.m')).toBeUndefined();
});
});
describe('Objective-C xcconfig / module map parsers', () => {
it('joins continuation lines and drops inherited tokens', () => {
expect(
parseXcconfigSearchPaths('HEADER_SEARCH_PATHS = \\\n $(PROJECT_DIR)/A \\\n $(inherited)\n'),
).toEqual({ headerSearchPaths: ['A'], userHeaderSearchPaths: [] });
});
it('resolves module umbrellas relative to the map directory', () => {
expect(
parseModuleMap(
'framework module MyKit { umbrella header "../Headers/MyKit.h" }',
'MyKit.framework/Modules',
),
).toEqual([['MyKit', 'MyKit.framework/Headers/MyKit.h']]);
});
});
describe('Objective-C workspace import resolution', () => {
const fromFile = 'Sources/main.m';
it('keeps the pre-workspace quoted suffix answers when no config is supplied', () => {
const files = new Set([
'Vendor/Widget.h',
'Headers/Widget.h',
'Sources/Local.h',
'Sources/main.m',
]);
expect(resolveObjectiveCImportTarget('Widget.h', fromFile, files)).toBe('Vendor/Widget.h');
expect(resolveObjectiveCImportTarget('./Local.h', fromFile, files)).toBe('Sources/Local.h');
expect(resolveObjectiveCImportTarget('<Foundation/Foundation.h>', fromFile, files)).toBeNull();
expect(resolveObjectiveCImportTarget('Foundation', fromFile, files)).toBeNull();
});
it('resolves a quoted header that is only in the scanned header set', () => {
const files = new Set(['Sources/main.m']);
const workspace = config({ headers: new Set(['Headers/Widget.h']) });
expect(resolveObjectiveCImportTarget('Widget.h', fromFile, files, workspace)).toBe(
'Headers/Widget.h',
);
expect(resolveObjectiveCImportTarget('Widget.h', fromFile, files)).toBeNull();
});
it('resolves quoted imports through user header search paths', () => {
const files = new Set(['Sources/main.m']);
const workspace = config({
headers: new Set(['Vendor/Headers/Helper.h']),
userHeaderSearchPaths: ['Vendor/Headers'],
});
expect(resolveObjectiveCImportTarget('Helper.h', fromFile, files, workspace)).toBe(
'Vendor/Headers/Helper.h',
);
});
it('resolves in-repo angle-bracket framework headers and keeps system decoys closed', () => {
const files = new Set(['Sources/main.m', 'Headers/Foundation.h']);
const workspace = config({
headers: new Set([
'MyKit.framework/Headers/Widget.h',
'Headers/Foundation.h',
'Headers/Foundation/Foundation.h',
]),
userHeaderSearchPaths: ['Headers'],
frameworks: new Map([['MyKit', 'MyKit.framework/Headers']]),
});
expect(resolveObjectiveCImportTarget('<MyKit/Widget.h>', fromFile, files, workspace)).toBe(
'MyKit.framework/Headers/Widget.h',
);
expect(
resolveObjectiveCImportTarget('<Foundation/Foundation.h>', fromFile, files, workspace),
).toBeNull();
expect(resolveObjectiveCImportTarget('Foundation.h', fromFile, files, workspace)).toBe(
'Headers/Foundation.h',
);
});
it('resolves @import through an in-repo module map only', () => {
const files = new Set(['Sources/main.m']);
const workspace = config({
headers: new Set(['MyKit.framework/Headers/MyKit.h']),
modules: new Map([['MyKit', 'MyKit.framework/Headers/MyKit.h']]),
});
expect(resolveObjectiveCImportTarget('MyKit', fromFile, files, workspace)).toBe(
'MyKit.framework/Headers/MyKit.h',
);
expect(resolveObjectiveCImportTarget('Foundation', fromFile, files, workspace)).toBeNull();
});
it('accepts a raw header Set the way the C bench threads resolutionConfig', () => {
const files = new Set(['Sources/main.m']);
expect(
resolveObjectiveCImportTarget('Widget.h', fromFile, files, new Set(['Headers/Widget.h'])),
).toBe('Headers/Widget.h');
});
});
describe('Objective-C compilation-unit siblings', () => {
it('shares defs between same-directory header and implementation only', () => {
const headerDef = def('header-run', 'Classes/Foo.h', 'FooRun');
const implDef = def('impl-helper', 'Classes/Foo.m', 'FooHelper');
const otherDef = def('other', 'Classes/Bar.m', 'BarOnly');
const parsedFiles = [
parsed('Classes/Foo.h', 'module:foo-h', headerDef),
parsed('Classes/Foo.m', 'module:foo-m', implDef),
parsed('Classes/Bar.m', 'module:bar-m', otherDef),
];
const indexes = siblingIndexes(parsedFiles);
populateObjectiveCCompilationUnitSiblings(parsedFiles, indexes);
expect(indexes.bindingAugmentations.get('module:foo-m')?.get('FooRun')?.[0]?.def.nodeId).toBe(
'header-run',
);
expect(
indexes.bindingAugmentations.get('module:foo-h')?.get('FooHelper')?.[0]?.def.nodeId,
).toBe('impl-helper');
expect(indexes.bindingAugmentations.get('module:bar-m')?.get('FooRun')).toBeUndefined();
});
it('pairs a header and implementation that declare the same class across directories', () => {
const headerPath = 'Headers/Widget.h';
const implPath = 'Sources/Widget.m';
setObjectiveCFileFacts({
...staleFacts(headerPath),
containers: [
{
kind: 'class',
declarationRole: 'interface',
name: 'Widget',
qualifiedName: 'objc:class:Widget',
nodeId: 'Class:objc:class:Widget',
label: 'Class',
filePath: headerPath,
startLine: 1,
endLine: 3,
protocols: [],
},
],
});
setObjectiveCFileFacts({
...staleFacts(implPath),
containers: [
{
kind: 'class',
declarationRole: 'implementation',
name: 'Widget',
qualifiedName: 'objc:class:Widget',
nodeId: 'Class:objc:class:Widget',
label: 'Class',
filePath: implPath,
startLine: 1,
endLine: 5,
protocols: [],
},
],
});
const headerDef = def('widget-iface', headerPath, 'WidgetIface');
const implDef = def('widget-impl', implPath, 'WidgetImpl');
const parsedFiles = [
parsed(headerPath, 'module:h', headerDef, {
kind: 'objective-c',
facts: getObjectiveCFileFacts(headerPath),
}),
parsed(implPath, 'module:m', implDef, {
kind: 'objective-c',
facts: getObjectiveCFileFacts(implPath),
}),
];
const indexes = siblingIndexes(parsedFiles);
populateObjectiveCCompilationUnitSiblings(parsedFiles, indexes);
expect(indexes.bindingAugmentations.get('module:m')?.get('WidgetIface')?.[0]?.def.nodeId).toBe(
'widget-iface',
);
});
it('does not expose file-static C functions to compilation-unit siblings', () => {
const headerPath = 'Classes/Foo.h';
const implPath = 'Classes/Foo.m';
const staticQn = objcFunctionQualifiedName('hiddenHelper', 'internal', implPath);
const staticDef: SymbolDefinition = {
nodeId: `Function:${staticQn}`,
filePath: implPath,
type: 'Function',
qualifiedName: staticQn,
};
const exportedDef = def('exported-run', implPath, 'FooRun');
setObjectiveCFileFacts({
...staleFacts(implPath),
functions: [
{
name: 'hiddenHelper',
linkage: 'internal',
qualifiedName: staticQn,
nodeId: staticDef.nodeId,
filePath: implPath,
startLine: 1,
endLine: 1,
parameterTypes: [],
},
],
});
const parsedFiles = [
parsed(headerPath, 'module:foo-h', def('header-run', headerPath, 'FooIface'), {
kind: 'objective-c',
facts: staleFacts(headerPath),
}),
{
filePath: implPath,
moduleScope: 'module:foo-m',
scopes: [],
parsedImports: [],
localDefs: [staticDef, exportedDef],
referenceSites: [],
captureSideChannel: { kind: 'objective-c', facts: getObjectiveCFileFacts(implPath) },
},
];
const indexes = siblingIndexes(parsedFiles);
populateObjectiveCCompilationUnitSiblings(parsedFiles, indexes);
expect(indexes.bindingAugmentations.get('module:foo-h')?.get('hiddenHelper')).toBeUndefined();
expect(indexes.bindingAugmentations.get('module:foo-h')?.get(staticQn)).toBeUndefined();
expect(indexes.bindingAugmentations.get('module:foo-h')?.get('FooRun')?.[0]?.def.nodeId).toBe(
'exported-run',
);
expect(objectiveCScopeResolver.isFileLocalDef?.(staticDef)).toBe(true);
expect(objectiveCScopeResolver.isFileLocalDef?.(exportedDef)).toBe(false);
});
it('does not mix a class and a same-named protocol into one visibility group', () => {
const classPath = 'Headers/Foo.h';
const protocolPath = 'Protocols/Foo.h';
setObjectiveCFileFacts({
...staleFacts(classPath),
containers: [
{
kind: 'class',
declarationRole: 'interface',
name: 'Foo',
qualifiedName: 'objc:class:Foo',
nodeId: 'Class:objc:class:Foo',
label: 'Class',
filePath: classPath,
startLine: 1,
endLine: 3,
protocols: [],
},
],
});
setObjectiveCFileFacts({
...staleFacts(protocolPath),
containers: [
{
kind: 'protocol',
declarationRole: 'interface',
name: 'Foo',
qualifiedName: 'objc:protocol:Foo',
nodeId: 'Protocol:objc:protocol:Foo',
label: 'Protocol',
filePath: protocolPath,
startLine: 1,
endLine: 3,
protocols: [],
},
],
});
const parsedFiles = [
parsed(classPath, 'module:class', def('class-only', classPath, 'classOnly'), {
kind: 'objective-c',
facts: getObjectiveCFileFacts(classPath),
}),
parsed(protocolPath, 'module:proto', def('proto-only', protocolPath, 'protoOnly'), {
kind: 'objective-c',
facts: getObjectiveCFileFacts(protocolPath),
}),
];
const indexes = siblingIndexes(parsedFiles);
populateObjectiveCCompilationUnitSiblings(parsedFiles, indexes);
expect(indexes.bindingAugmentations.get('module:proto')?.get('classOnly')).toBeUndefined();
expect(indexes.bindingAugmentations.get('module:class')?.get('protoOnly')).toBeUndefined();
});
});
function def(nodeId: string, filePath: string, name: string): SymbolDefinition {
return { nodeId, filePath, type: 'Function', qualifiedName: name };
}
function parsed(
filePath: string,
moduleScope: string,
localDef: SymbolDefinition,
captureSideChannel?: unknown,
): ParsedFile {
return {
filePath,
moduleScope,
scopes: [],
parsedImports: [],
localDefs: [localDef],
referenceSites: [],
captureSideChannel,
};
}
function siblingIndexes(parsedFiles: readonly ParsedFile[]): ScopeResolutionIndexes {
return {
moduleScopes: {
byFilePath: new Map(parsedFiles.map((file) => [file.filePath, file.moduleScope])),
},
imports: new Map(),
bindings: new Map(),
bindingAugmentations: new Map(),
} as unknown as ScopeResolutionIndexes;
}