fix(objc): a macro alone on its line no longer hides the declaration below it (#2691)

A macro on a line of its own, like NS_ASSUME_NONNULL_BEGIN,
OF_SUBCLASSING_RESTRICTED or RCT_EXTERN_C_BEGIN, reads to tree-sitter-objc
as the start of a declaration and swallows what follows: an @interface
under it became a function named after its last protocol, and a typedef
under it a variable `typedef` plus a variable named after the type. C and
C++ already blank such a macro in preParse (blankLoneMacroLines);
Objective-C had no preParse.

- c-cpp.ts: blankLoneMacroLines takes the language's rules, and C and C++
  keep theirs unchanged. OBJC_LONE_MACROS lets the code below the macro
  start with `@` (@interface, @end). It reads a preprocessor line above the
  macro as ending at its own line: `#import <Foundation/Foundation.h>` ends
  in `>`, an operator to C's rule, which left NS_ASSUME_NONNULL_BEGIN in
  place while the OF_SUBCLASSING_RESTRICTED below it was blanked, a worse
  parse than either. And it leaves a macro that is a method's whole body
  (`{ OF_UNRECOGNIZED_SELECTOR }`): blanking it changed how the parser
  recovered from an earlier misread, and OFMutableString.m lost 11 calls.
- objc.ts: preParse blanks with those rules.
- name-matcher.ts: objcSupertypesOf reads the superclass from the
  @interface line within the class rather than the class's first line. A
  class now starts at an attribute macro written above its @interface, like
  NS_SWIFT_NAME(...), and KSCrash's [super validateSetupWithError:] had lost
  its superclass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-20 00:41:26 +00:00
committed by GitHub
parent 6c202bca32
commit 40987d7776
5 changed files with 218 additions and 6 deletions
+1
View File
@@ -181,6 +181,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- `codegraph_explore`'s Flow now goes through the implementation your query names when an interface method has several that lead to the same place. Before, it took whichever one came first in the index: asking about prometheus's `Engine.execEvalStmt Queryable.Querier fanout.Querier NewMergeQuerier` traced the call through the TSDB's `DB.Querier` and left the `fanout.Querier` you named off the Flow. Any two routes of the same length are now settled this way, in favor of the one that passes through more of the symbols you named.
- In C and C++, a struct, class, union or enum defined in a variable declaration, like `struct Foo { … } foo;`, is now indexed with its methods and enum values, next to the variable. Before, at most the variable was, so the type was missing from search and `codegraph_explore`, a class deriving from it had no base, and calls to its methods linked to nothing: in redis, the Sentinel's `sentinelState` and `redis-cli`'s `config` struct were missing. A struct, union or enum with no name of its own now takes the name of the variable it declares, so redis's `static struct { … } SPT;` is found as `SPT`, and one declared inside a function is named after its variable instead of `<anonymous>`. The comment above the declaration becomes the type's description. Re-index C and C++ projects after upgrading.
- In Objective-C, a struct, union or enum defined in a variable declaration, like `static struct { Class isa; } placeholder;` or `enum { stateType, stateParamName } state;`, is now indexed with its enum values, next to the variable, as in C and C++. Before, only the variable was, so the type was missing from search and `codegraph_explore`. The same went for a `typedef enum { … } Name;` or `typedef struct … Name;` written after a line holding only a macro, like `OF_ASSUME_NONNULL_BEGIN` or `RCT_EXTERN_C_BEGIN`: ObjFW's `OFZIPArchiveEntryCompressionMethod` and every value in it were missing, and so was React Native's `RCTMethodInfo`. A struct, union or enum with no name of its own takes the name of the variable it declares, so ObjFW's class-cluster placeholders are found as `placeholder`, one declared inside a function or method is named after its variable instead of `<anonymous>`, and the comment above the declaration becomes the type's description. As in C, a type that a `.m` file defines links only from that file, so a call elsewhere that just shares its name, like ObjFW's calls of AmigaOS's `Lock(path, mode)`, doesn't link to the runtime's private `struct Lock`. Re-index Objective-C projects after upgrading.
- In Objective-C, a macro written alone on its line, like `NS_ASSUME_NONNULL_BEGIN`, `OF_SUBCLASSING_RESTRICTED` or React Native's `RCT_EXTERN_C_BEGIN`, no longer hides the declaration below it. Before, the parser read such a macro as the start of a declaration, so an `@interface` under it could come out as a function and a `typedef` under it as two variables: many of ObjFW's classes, with their properties and methods and the classes they extend and protocols they adopt, were missing, and so were classes and protocols in React Native and KSCrash headers. A message to `super` from a class whose `@interface` follows an attribute macro like `NS_SWIFT_NAME(…)` still reaches the superclass. Re-index Objective-C projects after upgrading.
- In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div<WrapperProps>` followed by its CSS, or `styled(Base)<Props>`, is now indexed as a component, as one written without a type argument already was. Before, it was indexed as a plain constant, so `codegraph callers` and impact found none of the code that renders it, and its tags could link to another file's component of the same name instead: in outline/outline, `NudeButton` and `HStack` had no callers, and `<Text>` tags linked to a different `Text` than the one each file imports. Re-index TypeScript React projects after upgrading.
- `codegraph sync` and the file watcher now move a React Router route to its page when the router file loads that page lazily into a value of its own, like `const Docs = lazy(() => import('./pages/Docs'))` rendered by `<Route path="/docs" element={<Docs />} />`, and the page's file is added after the router was indexed, comes back after being deleted, or gets or changes its default export in a later edit. Before, the route kept pointing at the `Docs` declaration, or at the page's old component, until the router file itself changed or the project was re-indexed, unless the page's component happened to share the declaration's name. The same now holds for a `lazy: () => import('./pages/Docs')` route whose page switches its default export to another component. Re-index React projects after upgrading to correct routes an earlier version left behind.
- `codegraph sync` and the file watcher now link a component to the JSX that renders it, like `<Team />`, when the component is added in a file with no JSX of its own, as in `export default function Team() { return null; }`. Before, the link waited for an unrelated change to a file with JSX, or for a re-index, so the new component looked unused.
+170
View File
@@ -0,0 +1,170 @@
/**
* A macro alone on its line in Objective-C, like `NS_ASSUME_NONNULL_BEGIN`,
* `OF_SUBCLASSING_RESTRICTED` or `RCT_EXTERN_C_BEGIN`, reads to the parser as
* the start of a declaration. It swallowed what follows:
*
* NS_ASSUME_NONNULL_BEGIN
* typedef struct { … } SDImageSize; // a variable `typedef` and a variable `SDImageSize`
* OF_SUBCLASSING_RESTRICTED
* @interface SDImageCache : NSObject <NSCacheDelegate> { … } // a function `NSCacheDelegate`
*
* so a header's classes, properties and conformances went missing. C and C++
* blank such a macro before parsing; Objective-C now does too, with its own
* reading of the lines around it: the code below may be a directive
* (`@interface`, `@end`), and an `#import <…>` above it continues nothing. A
* macro that is a method's whole body stays: blanking it changed how the
* parser recovered elsewhere in the file.
*
* A class now starts at an attribute macro written above its `@interface`
* (`NS_SWIFT_NAME(…)`), so a message to `super` looks for the superclass on
* the `@interface` line, not the class's first line.
*/
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
import { extractFromSource } from '../src/extraction';
import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
import { blankLoneMacroLines, OBJC_LONE_MACROS } from '../src/extraction/languages/c-cpp';
import type { ExtractionResult } from '../src/types';
/** `<kind> <qualifiedName>` for every symbol, file and imports aside. */
function symbols(result: ExtractionResult): string[] {
return result.nodes
.filter((n) => n.kind !== 'file' && n.kind !== 'import')
.map((n) => `${n.kind} ${n.qualifiedName}`);
}
const HEADER = [
'#import <Foundation/Foundation.h>',
'',
'NS_ASSUME_NONNULL_BEGIN',
'',
'typedef struct {',
' int width;',
'} SDImageSize;',
'',
'OF_SUBCLASSING_RESTRICTED',
'@interface SDImageCache : NSObject <NSCacheDelegate>',
'{',
' int _count;',
'}',
'@property (nonatomic) int limit;',
'- (void)clear;',
'@end',
'',
'NS_ASSUME_NONNULL_END',
'',
].join('\n');
describe('a macro alone on its line in Objective-C', () => {
beforeAll(async () => {
await initGrammars();
await loadGrammarsForLanguages(['objc']);
});
for (const crlf of [false, true]) {
const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s);
const label = crlf ? ' (CRLF)' : '';
it(`no longer swallows the typedef or the @interface below it${label}`, () => {
const result = extractFromSource('src/SDImageCache.h', eol(HEADER), 'objc');
expect(symbols(result)).toEqual(['struct SDImageSize', 'class SDImageCache', 'property SDImageCache::limit']);
const supertypes = result.unresolvedReferences
.filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements')
.map((r) => `${r.referenceKind} ${r.referenceName}`);
expect(supertypes).toEqual(['extends NSObject', 'implements NSCacheDelegate']);
});
it(`is blanked in place, and stays when it is a method's whole body${label}`, () => {
const source = eol([
'#import <Foundation/Foundation.h>',
'NS_ASSUME_NONNULL_BEGIN',
'@implementation SDImageCache',
'- (void)clear',
'{',
'\tOF_UNRECOGNIZED_SELECTOR',
'}',
'OF_SINGLETON_METHODS',
'@end',
'NS_ASSUME_NONNULL_END',
'',
].join('\n'));
const blanked = blankLoneMacroLines(source, OBJC_LONE_MACROS);
expect(blanked.length).toBe(source.length);
expect(blanked).not.toMatch(/NS_ASSUME_NONNULL_BEGIN|NS_ASSUME_NONNULL_END|OF_SINGLETON_METHODS/);
expect(blanked).toContain('\tOF_UNRECOGNIZED_SELECTOR');
});
}
});
describe('an indexed Objective-C project with lone macro lines', () => {
let root = '';
let cg: CodeGraph | undefined;
afterEach(() => {
cg?.destroy();
cg = undefined;
if (root) fs.rmSync(root, { recursive: true, force: true });
root = '';
});
it('sends `super` to the superclass of a class an attribute macro opens', async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-objc-lone-macros-'));
const files: Record<string, string> = {
'src/KSInstallation.h': [
'#import <Foundation/Foundation.h>',
'',
'NS_ASSUME_NONNULL_BEGIN',
'',
'@interface KSInstallation : NSObject',
'- (BOOL)validate;',
'@end',
'',
'NS_ASSUME_NONNULL_END',
'',
].join('\n'),
'src/KSInstallation.m': ['#import "KSInstallation.h"', '', '@implementation KSInstallation', '- (BOOL)validate', '{', '\treturn YES;', '}', '@end', ''].join('\n'),
'src/KSStandardInstallation.h': [
'#import <Foundation/Foundation.h>',
'#import "KSInstallation.h"',
'',
'NS_ASSUME_NONNULL_BEGIN',
'',
'NS_SWIFT_NAME(StandardInstallation)',
'@interface KSStandardInstallation : KSInstallation',
'@end',
'',
'NS_ASSUME_NONNULL_END',
'',
].join('\n'),
'src/KSStandardInstallation.m': [
'#import "KSStandardInstallation.h"',
'',
'@implementation KSStandardInstallation',
'- (BOOL)validate',
'{',
'\treturn [super validate];',
'}',
'@end',
'',
].join('\n'),
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
const classes = cg.getNodesByName('KSStandardInstallation').filter((n) => n.kind === 'class').map((n) => n.filePath).sort();
expect(classes).toEqual(['src/KSStandardInstallation.h', 'src/KSStandardInstallation.m']);
const caller = cg.getNodesByName('validate').find((n) => n.qualifiedName === 'KSStandardInstallation::validate')!;
expect(caller).toBeDefined();
const callees = cg
.getOutgoingEdges(caller.id)
.filter((e) => e.kind === 'calls')
.map((e) => cg!.getNode(e.target)?.qualifiedName);
expect(callees).toEqual(['KSInstallation::validate']);
}, 60_000);
});
+36 -4
View File
@@ -1214,11 +1214,41 @@ export function blankCppAnnotationMacroCalls(source: string): string {
* string literal, or `;` continuation rejects the match. Comments in
* between are skipped: {fmt}'s `FMT_BEGIN_EXPORT` sits above `// A
* generic formatting context …` and then the class it opens.
* Shared by C and C++ (the idiom is identical in both).
* Shared by C and C++ (the idiom is identical in both), and by Objective-C
* through OBJC_LONE_MACROS.
*/
const LONE_MACRO_LINE_RE = /^[ \t]*([A-Z][A-Z0-9_]{3,})[ \t]*(?:\/\/[^\n\r]*|\/\*[^\n\r]*\*\/[ \t]*)?\r?$/;
const LONE_MACRO_CONTINUATION_END_RE = /[=+\-*/%&|^<>?:,(\\]$/;
export function blankLoneMacroLines(source: string): string {
/** How a language's code reads around a macro alone on its line. */
interface LoneMacroRules {
/** What the code after the macro may start with. */
nextCode: RegExp;
/** Whether a preprocessor directive above the macro ends at its own line. */
directivesEnd: boolean;
/** Whether a macro that is a body's only statement, between `{` and `}`, stays. */
keepsSoleStatement: boolean;
}
const C_LONE_MACROS: LoneMacroRules = { nextCode: /[A-Za-z_#{}~]/, directivesEnd: false, keepsSoleStatement: false };
/**
* Objective-C's: the code after the macro may be a directive, like the
* `@interface` below `NS_ASSUME_NONNULL_BEGIN` or the `@end` below
* `OF_SINGLETON_METHODS`. A preprocessor line above it continues nothing,
* even one that ends in `>`, like the `#import <Foundation/Foundation.h>`
* above most headers' `NS_ASSUME_NONNULL_BEGIN`. Blanking that macro but not
* another before the same `@interface` (`OF_SUBCLASSING_RESTRICTED`) left
* `OF_ASSUME_NONNULL_BEGIN @interface …`, which reads as a function.
*
* A macro that is a method's whole body, like ObjFW's `{
* OF_UNRECOGNIZED_SELECTOR }`, stays: the parser's error there stays inside
* that body, but blanking it changed how the parser recovered from an
* earlier misread, and OFMutableString.m lost 11 calls in the methods below.
*/
export const OBJC_LONE_MACROS: LoneMacroRules = { nextCode: /[A-Za-z_#{}~@]/, directivesEnd: true, keepsSoleStatement: true };
export function blankLoneMacroLines(source: string, rules: LoneMacroRules = C_LONE_MACROS): string {
if (!/^[ \t]*[A-Z][A-Z0-9_]{3,}[ \t]*\r?$/m.test(source)) return source;
const lines = source.split('\n');
const content = (l: string): string => l.replace(/\r$/, '').trim();
@@ -1239,9 +1269,11 @@ export function blankLoneMacroLines(source: string): string {
// continues nothing either.
const prevText = prev >= 0 ? content(lines[prev] as string) : '';
const prevCode = /^(?:\/\/|\/\*|\*)/.test(prevText) ? '' : prevText.replace(/\/\*.*?\*\/|\/\/.*$/g, '').trim();
if (prevCode !== '' && !/^template\s*</.test(prevCode) && LONE_MACRO_CONTINUATION_END_RE.test(prevCode)) continue;
const directiveEnds = rules.directivesEnd && prevCode.startsWith('#') && !prevCode.endsWith('\\');
if (prevCode !== '' && !directiveEnds && !/^template\s*</.test(prevCode) && LONE_MACRO_CONTINUATION_END_RE.test(prevCode)) continue;
const first = firstCodeCharAfter(lines, i + 1);
if (first !== undefined && !/[A-Za-z_#{}~]/.test(first)) continue;
if (first !== undefined && !rules.nextCode.test(first)) continue;
if (rules.keepsSoleStatement && prevCode.endsWith('{') && first === '}') continue;
const start = line.indexOf(m[1] as string);
lines[i] =
line.slice(0, start) + ' '.repeat((m[1] as string).length) + line.slice(start + (m[1] as string).length);
+6 -1
View File
@@ -1,7 +1,7 @@
import type { Node as SyntaxNode } from 'web-tree-sitter';
import { getChildByField, getNodeText } from '../tree-sitter-helpers';
import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types';
import { definingDeclaration, unnamedTypeVariableName } from './c-cpp';
import { blankLoneMacroLines, definingDeclaration, OBJC_LONE_MACROS, unnamedTypeVariableName } from './c-cpp';
function findCompoundStatement(node: SyntaxNode): SyntaxNode | null {
for (let i = 0; i < node.namedChildCount; i++) {
@@ -97,6 +97,11 @@ function extractObjcPropertyName(node: SyntaxNode, source: string): string | nul
}
export const objcExtractor: LanguageExtractor = {
// A macro alone on its line, like `NS_ASSUME_NONNULL_BEGIN` or
// `RCT_EXTERN_C_BEGIN`, reads to the parser as the start of a declaration:
// it swallowed the `typedef` below it, and an `@interface` below it lost
// its class to error recovery. Blanked as in C, offsets kept.
preParse: (source) => blankLoneMacroLines(source, OBJC_LONE_MACROS),
functionTypes: ['function_definition'],
// Only @interface emits a class node; @implementation reuses it via visitNode.
classTypes: ['class_interface'],
+5 -1
View File
@@ -7036,7 +7036,11 @@ function objcSupertypesOf(name: string, context: ResolutionContext): string[] {
const supers: string[] = [];
for (const decl of context.getNodesByName(name)) {
if (decl.kind !== 'class' || decl.language !== 'objc') continue;
const line = context.getFileLines?.(decl.filePath)?.[decl.startLine - 1] ?? context.readFile(decl.filePath)?.split('\n')[decl.startLine - 1] ?? '';
// The class may open with an attribute macro on a line of its own, like
// `NS_SWIFT_NAME(CrashInstallationStandard)` above KSCrash's
// `@interface KSCrashInstallationStandard : KSCrashInstallation`.
const lines = context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split('\n') ?? [];
const line = lines.slice(decl.startLine - 1, decl.endLine).find((l) => l.includes('@interface')) ?? '';
const sup = /@interface\s+\w+\s*:\s*(\w+)/.exec(line)?.[1];
if (sup && !supers.includes(sup)) supers.push(sup);
}