From 1b12bf5b498b65620bf2dd7f58cd31f4a19c8097 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 00:59:54 +0000 Subject: [PATCH 1/8] fix(objc,c,cpp): an enum declared with NS_ENUM, NS_OPTIONS or CF_ENUM is indexed (#2694) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation's enum macros declare most enums in Objective-C and in the C and C++ headers beside it: `typedef NS_ENUM(NSInteger, SDImageCacheType) { … };`. The parser reads the macro as a broken typedef, so the enum and its values were never indexed (SDWebImage had no enum at all), and a junk type alias named after the first value, or after `(NSInteger, Name)` in C++, stood in their place. - c-cpp.ts: rewriteFoundationEnumMacros rewrites the macro in place as `enum Name {`. The `typedef`, the rest of the macro's name, its parentheses and its first argument (the underlying type, or an NS_ERROR_ENUM's domain) become spaces, so every offset stays. It matches on the code with comments, literals and directives masked, so the macro's own `#define` is left alone. NS_ENUM, NS_OPTIONS, NS_CLOSED_ENUM, NS_ERROR_ENUM, CF_ENUM, CF_OPTIONS and CF_CLOSED_ENUM share the shape. The C and C++ preParse run it first. - objc.ts: preParse runs it before the lone-macro blank. The kernel takes C and C++ input from the hoisted preParse, so it needs no change. On the five repos' 1,342 C/C++ files the kernel and wasm agree byte for byte, and 17 more files now parse clean enough for the kernel to take them. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/foundation-enum-macros.test.ts | 174 +++++++++++++++++++++++ src/extraction/languages/c-cpp.ts | 46 +++++- src/extraction/languages/objc.ts | 13 +- 4 files changed, 229 insertions(+), 5 deletions(-) create mode 100644 __tests__/foundation-enum-macros.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b12da8fd8..d5001316b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -182,6 +182,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 ``. 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 ``, 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 Objective-C, and in the C and C++ headers that sit beside it, an enum declared with Foundation's macros, like `typedef NS_ENUM(NSInteger, SDImageCacheType) { … };`, `NS_OPTIONS`, `NS_CLOSED_ENUM`, `NS_ERROR_ENUM` or CoreFoundation's `CF_ENUM` and `CF_OPTIONS`, is now indexed with its values. Before, the parser read the macro as a broken typedef, so these enums were missing (SDWebImage had none at all) and a type alias named after the first value stood in their place. A Swift annotation naming such an enum, like KSCrash's `let unwindMethods: KSBacktraceUnwindMethods`, now links to it. Re-index Objective-C, C and C++ projects after upgrading. - In TypeScript React code, a styled-components or emotion component declared with a type argument, like `const Wrapper = styled.div` followed by its CSS, or `styled(Base)`, 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 `` 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 `} />`, 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 ``, 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. diff --git a/__tests__/foundation-enum-macros.test.ts b/__tests__/foundation-enum-macros.test.ts new file mode 100644 index 000000000..f6412dca7 --- /dev/null +++ b/__tests__/foundation-enum-macros.test.ts @@ -0,0 +1,174 @@ +/** + * Foundation's enum macros declare most enums in Objective-C code and in the + * C and C++ headers that live beside it: + * + * typedef NS_ENUM(NSInteger, SDImageCacheType) { SDImageCacheTypeNone, … }; + * typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) { … }; + * typedef CF_ENUM(CFIndex, KSCrashMonitorType) { … }; + * + * The parser reads the macro as a broken typedef, so the enum and its values + * were never indexed (SDWebImage had no enum at all), and a junk type alias + * named after the first value took its place. Objective-C, C and C++ now + * rewrite the macro as `enum SDImageCacheType {` before parsing, at the same + * offsets. Runs the C and C++ cases against the native kernel (when built) + * and the wasm extractor, which must agree. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { rewriteFoundationEnumMacros } from '../src/extraction/languages/c-cpp'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +/** ` ` 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}`); +} + +describe('Foundation enum macros', () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['objc', 'c', 'cpp']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it(`declare enums and their values in Objective-C${label}`, () => { + const source = [ + '#import ', + '', + '/// Where an image came from.', + 'typedef NS_ENUM(NSInteger, SDImageCacheType) {', + ' SDImageCacheTypeNone,', + ' SDImageCacheTypeDisk,', + '};', + '', + 'typedef NS_OPTIONS(NSUInteger, SDWebImageOptions) {', + ' SDWebImageRetryFailed = 1 << 0,', + '};', + '', + 'typedef NS_ERROR_ENUM(SDWebImageErrorDomain, SDWebImageError) {', + ' SDWebImageErrorInvalidURL = 1000,', + '};', + '', + 'typedef NS_ENUM(NSInteger, SDForwardDeclared);', + '', + '@interface SDImageCache : NSObject', + '@end', + '', + ].join('\n'); + const result = extractFromSource('src/SDImageCacheDefine.h', eol(source), 'objc'); + expect(symbols(result)).toEqual([ + 'enum SDImageCacheType', + 'enum_member SDImageCacheType::SDImageCacheTypeNone', + 'enum_member SDImageCacheType::SDImageCacheTypeDisk', + 'enum SDWebImageOptions', + 'enum_member SDWebImageOptions::SDWebImageRetryFailed', + 'enum SDWebImageError', + 'enum_member SDWebImageError::SDWebImageErrorInvalidURL', + 'class SDImageCache', + ]); + expect(result.nodes.find((n) => n.name === 'SDImageCacheType')?.docstring).toBe('Where an image came from.'); + }); + + it.each(backends)(`declare enums in C and C++ headers: %s${label}`, (backend) => { + const c = [ + '#include ', + '', + 'typedef CF_ENUM(CFIndex, KSCrashMonitorType) {', + ' KSCrashMonitorTypeNone = 0,', + ' KSCrashMonitorTypeSignal = 1,', + '} CF_SWIFT_NAME(CrashMonitorType);', + '', + ].join('\n'); + expect(symbols(extract(backend, 'src/KSCrashMonitorType.h', eol(c), 'c'))).toEqual([ + 'enum KSCrashMonitorType', + 'enum_member KSCrashMonitorType::KSCrashMonitorTypeNone', + 'enum_member KSCrashMonitorType::KSCrashMonitorTypeSignal', + ]); + const cpp = [ + '#include ', + '', + 'namespace facebook { class Report {}; }', + '', + 'typedef NS_ENUM(NSInteger, RCTSchedulerMode) {', + ' RCTSchedulerModeSync,', + '};', + '', + ].join('\n'); + expect(symbols(extract(backend, 'src/RCTScheduler.h', eol(cpp), 'cpp'))).toEqual([ + 'class facebook::Report', + 'enum RCTSchedulerMode', + 'enum_member RCTSchedulerMode::RCTSchedulerModeSync', + ]); + }); + + it(`are rewritten in place, and the macro's own #define is left alone${label}`, () => { + const source = eol([ + '#define NS_ENUM(_type, _name) enum _name : _type _name; enum _name : _type', + '// typedef NS_ENUM(NSInteger, InAComment) {', + 'typedef NS_ENUM(NSInteger, SDImageCacheType) {', + ' SDImageCacheTypeNone,', + '};', + '', + ].join('\n')); + const rewritten = rewriteFoundationEnumMacros(source); + expect(rewritten.length).toBe(source.length); + expect(rewritten.split(/\r?\n/)).toEqual([ + '#define NS_ENUM(_type, _name) enum _name : _type _name; enum _name : _type', + '// typedef NS_ENUM(NSInteger, InAComment) {', + ' enum SDImageCacheType {', + ' SDImageCacheTypeNone,', + '};', + '', + ]); + }); + } +}); diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 377888914..8e75f9454 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -1638,9 +1638,51 @@ export function flattenMidStatementConditionals(source: string): string { return changed ? lines.join('\n') : source; } +// Foundation's enum macros and their CoreFoundation forms, with an optional +// `typedef` before them: `typedef NS_ENUM(NSInteger, SDImageCacheType)`. +const FOUNDATION_ENUM_MACRO_RE = + /\b(?:(typedef)(\s+))?(NS_ENUM|NS_OPTIONS|NS_CLOSED_ENUM|NS_ERROR_ENUM|CF_ENUM|CF_OPTIONS|CF_CLOSED_ENUM)(\s*)\(([^(),]*),(\s*)([A-Za-z_]\w*)(\s*)\)/g; + +/** + * Rewrite a Foundation enum macro as the enum it declares, in place: + * `typedef NS_ENUM(NSInteger, SDImageCacheType) {` becomes + * ` enum SDImageCacheType {`. The parser reads the + * macro as a broken typedef, so the enum and every value in it were missing + * (SDWebImage had no enum at all). The `typedef`, the rest of the macro's + * name, its parentheses and its first argument (the underlying type, or an + * NS_ERROR_ENUM's domain) become spaces, so every offset stays. Matched on + * the code with comments, literals and directives masked: the `#define` of + * the macro itself is left alone. Shared by Objective-C, C and C++, whose + * headers all use the macros. + */ +export function rewriteFoundationEnumMacros(source: string): string { + if (!/\b(?:NS|CF)_[A-Z_]*(?:ENUM|OPTIONS)\s*\(/.test(source)) return source; + let out: string[] | null = null; + for (const m of maskCppCode(source).matchAll(FOUNDATION_ENUM_MACRO_RE)) { + out ??= source.split(''); + const blank = (from: number, to: number): void => { + for (let k = from; k < to; k++) if (out![k] !== '\n' && out![k] !== '\r') out![k] = ' '; + }; + let at = m.index; + if (m[1]) { + blank(at, at + m[1].length); + at += m[1].length + (m[2] as string).length; + } + const macro = m[3] as string; + out.splice(at, 4, 'e', 'n', 'u', 'm'); + blank(at + 4, at + macro.length); + at += macro.length + (m[4] as string).length; + // `(`, the first argument and the `,`. + blank(at, at + 1 + (m[5] as string).length + 1); + at += 1 + (m[5] as string).length + 1 + (m[6] as string).length + (m[7] as string).length + (m[8] as string).length; + blank(at, at + 1); // `)` + } + return out ? out.join('') : source; +} + function preParseCppSource(source: string, filePath?: string): string { const rawStrings = maskCppRawStrings(source); - source = flattenMidStatementConditionals(rewriteCppAccessMacros(rawStrings.source)); + source = flattenMidStatementConditionals(rewriteCppAccessMacros(rewriteFoundationEnumMacros(rawStrings.source))); // blankCLeadingAttrMacros runs AFTER the api-prefix blank so a stacked // `FMT_NORETURN FMT_API void f(…)` reduces to the `MACRO Ret name(` shape // it matches (the _API token is already spaces by then). The C++ leading @@ -2430,7 +2472,7 @@ export function blankCDesignatedMacroArgs(source: string): string { function preParseCSource(source: string): string { const rawStrings = maskCppRawStrings(source); - source = rawStrings.source; + source = rewriteFoundationEnumMacros(rawStrings.source); const inner = blankCDesignatedMacroArgs(blankCKernelAnnotations(blankCCplusplusGuardBodies(source))); let blanked = blankCLeadingAttrMacros( blankLoneMacroLines( diff --git a/src/extraction/languages/objc.ts b/src/extraction/languages/objc.ts index b8a94489c..88fdc9292 100644 --- a/src/extraction/languages/objc.ts +++ b/src/extraction/languages/objc.ts @@ -1,7 +1,13 @@ 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 { blankLoneMacroLines, definingDeclaration, OBJC_LONE_MACROS, unnamedTypeVariableName } from './c-cpp'; +import { + blankLoneMacroLines, + definingDeclaration, + OBJC_LONE_MACROS, + rewriteFoundationEnumMacros, + unnamedTypeVariableName, +} from './c-cpp'; function findCompoundStatement(node: SyntaxNode): SyntaxNode | null { for (let i = 0; i < node.namedChildCount; i++) { @@ -100,8 +106,9 @@ 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), + // its class to error recovery. Blanked as in C, offsets kept. A + // `typedef NS_ENUM(NSInteger, Name) {` is rewritten as `enum Name {`. + preParse: (source) => blankLoneMacroLines(rewriteFoundationEnumMacros(source), OBJC_LONE_MACROS), functionTypes: ['function_definition'], // Only @interface emits a class node; @implementation reuses it via visitNode. classTypes: ['class_interface'], From 6f8e6caad93d12b9418720cc90fd279ccd4a8233 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:00:25 +0000 Subject: [PATCH 2/8] fix(c,cpp): a source implements the header beside it that it reaches through other headers (#2693) upb's reflection sources include only their internal header: upb/reflection/field_def.c includes upb/reflection/internal/field_def.h, which reaches the upb/reflection/field_def.h it implements through def.h. So the translation-unit rule of PR 2684 never read field_def.c as that header's implementation, and 343 calls from protobuf's Python extension, plus 88 from its Lua binding, still went to the copy of upb that the PHP extension bundles in php-upb.c. When a source includes no header named like it in its own folder, one there that it includes through other headers counts. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/cpp-translation-unit.test.ts | 36 ++++++++++++++++++++++- src/resolution/cpp-includers.ts | 40 +++++++++++++++++++++++++- src/resolution/name-matcher.ts | 10 +++++-- 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5001316b..3d7e1d080 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -132,6 +132,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, a name written in a googletest `TEST_F` or `TEST_P` body now finds the test fixture's nested types, those of its base classes included, before anything in the surrounding namespace, as C++ does for the member function such a body is: facebook/rocksdb's `new TestUserDefinedIndexFactory()` links to the class nested in the fixture's base. A class declared inside a test body, or inside any function, is now only found from that function, so the local classes many RocksDB tests each declare under one name, like `FakeWF` or `MyManager`, no longer stand in for one another, and a call like `fwf->SetIOError(true)` reaches the `FakeWF` its own test declares. Re-index C++ projects after upgrading. - In C and C++ tests, a name that several files declare now links to the one the test's own includes declare, when there is one: protocolbuffers/protobuf's C# generator tests call the `TestSourceDir()` that `googletest.h` declares, not another test helper's they never include; its upb tests call `upb_Arena_New()` and the rest of upb's C API in upb's own files, not the copies bundled for PHP and Ruby, which had left those calls unlinked; and facebook/rocksdb's C API test reaches `rocksdb_open` and its siblings in `db/c.cc`. Re-index C and C++ projects after upgrading. - In C and C++ code outside tests too, a name that several files declare now links to the one the file's own includes declare, when there is one: protocolbuffers/protobuf's Python extension calls functions like `upb_Encode()` in upb's own files rather than the copies bundled for PHP, and facebook/rocksdb's stress tool calls the `StringToHex()` its own header declares rather than `LDBCommand::StringToHex`. Re-index C and C++ projects after upgrading. +- In C and C++, a call of a function a header declares now links to its definition even when the file that defines it reaches that header only through other headers: protocolbuffers/protobuf's Python extension calls upb's reflection functions, like `upb_FieldDef_Number()`, in upb's own files rather than the copies bundled for PHP. Re-index C and C++ projects after upgrading. - In C++, a declaration continued on a line that starts with `*`, like facebook/rocksdb's `const auto& el =` over `*reinterpret_cast*>(jhandle);`, is now read, so calls such as `el->OnFlushCompleted(…)` link to `EventListener`'s methods; such a line used to be skipped as part of a comment. Code commented out in a `/* … */` block, and the text of a string literal, like protocolbuffers/protobuf's `[[deprecated("Please use SpaceUsedLong() instead")]]`, are no longer read as declarations, which had sent a call to the wrong method or to none. Re-index C++ projects after upgrading. - In C++, a call with a comparison or a shift among its arguments, like protocolbuffers/protobuf's `p->Emit({{"declare", has_bit_word_index < 0 ? … : ""}, …}, "…")` or `WriteVarint(num << 3, s)`, is now read with the right number of arguments, so it links to the overload that fits rather than one taking fewer. A parameter or variable whose type has template arguments is now read even after another such parameter on the same line, like facebook/rocksdb's `const autovector& cf_ids`, or with empty ones, like fmt's `parse_context<>& ctx`: calls on it link to that class's methods, and calls on a `std::vector` declared that way no longer link to a project class's method of the same name. Re-index C++ projects after upgrading. - In C++, a call passing `nullptr` now links to the overload that takes a `std::nullptr_t`, as C++ picks it, rather than one taking a pointer, and a call no longer links to an overload declared `= delete`, which no call that compiles can reach: protocolbuffers/protobuf's `ptr.reset(nullptr)` on an `Arena::UniquePtr` reaches `reset(std::nullptr_t)`, and `field.Add("foo")` on a `RepeatedPtrField` reaches `Add(Element&&)` instead of the deleted `Add(const Element&)`. Re-index C++ projects after upgrading. diff --git a/__tests__/cpp-translation-unit.test.ts b/__tests__/cpp-translation-unit.test.ts index 7c1f24bc0..3ba7f2453 100644 --- a/__tests__/cpp-translation-unit.test.ts +++ b/__tests__/cpp-translation-unit.test.ts @@ -7,7 +7,10 @@ * helpers.h declares, not `ImmutableFieldGenerator::HasHasbit`, a method * beside the caller. A source implements the header beside it (the * Python extension's pyext/message.cc is no implementation of - * google/protobuf/message.h), a header's inline overload reaches the + * google/protobuf/message.h), even one it reaches only through other + * headers (upb's reflection/field_def.c includes + * reflection/internal/field_def.h, which reaches reflection/field_def.h + * through def.h), a header's inline overload reaches the * definition its own source holds, and a call keeps a function outside the * unit that takes its arguments over one inside that can't (glog's * three-argument `GetStackTrace(…)` is not utilities.h's `GetStackTrace()`). @@ -44,6 +47,7 @@ int upb_Encode(const void* msg, int options, char** buf, size_t* size) { 'php/ext/google/protobuf/php-upb.h': `#include int upb_Encode(const void* msg, int options, char** buf, size_t* size); +int upb_FieldDef_Number(const void* f); `, 'php/ext/google/protobuf/php-upb.c': `#include "php-upb.h" @@ -51,6 +55,30 @@ int upb_Encode(const void* msg, int options, char** buf, size_t* size) { *size = 0; return 0; } + +int upb_FieldDef_Number(const void* f) { return 0; } +`, + // upb's reflection/field_def.c implements reflection/field_def.h, which + // it reaches only through its internal header and def.h. + 'upb/reflection/field_def.h': `typedef struct upb_FieldDef upb_FieldDef; + +int upb_FieldDef_Number(const upb_FieldDef* f); +`, + 'upb/reflection/def.h': `#include "upb/reflection/field_def.h" +`, + 'upb/reflection/internal/field_def.h': `#include "upb/reflection/def.h" + +int _upb_FieldDef_Index(const upb_FieldDef* f); +`, + 'upb/reflection/field_def.c': `#include "upb/reflection/internal/field_def.h" + +int upb_FieldDef_Number(const upb_FieldDef* f) { return 1; } +`, + 'python/convert.c': `#include "upb/reflection/def.h" + +int PyUpb_FieldNumber(const upb_FieldDef* f) { + return upb_FieldDef_Number(f); +} `, 'python/message.c': `#include "upb/wire/encode.h" @@ -344,6 +372,12 @@ describe('a C/C++ reference links what its translation unit declares', () => { expect(calls.some((c) => c.includes('php-upb'))).toBe(false); }); + it('reads a source as implementing the header beside it that it reaches through others', () => { + const calls = callsFrom('python/convert.c'); + expect(calls).toContain('upb_FieldDef_Number (upb/reflection/field_def.c)'); + expect(calls.some((c) => c.includes('php-upb'))).toBe(false); + }); + it('calls the free function its includes declare, not a class\'s method beside it', () => { const calls = callsFrom('src/google/protobuf/compiler/java/full/enum_field.cc'); expect(calls).toContain('google::protobuf::compiler::java::HasHasbit (src/google/protobuf/compiler/java/helpers.h)'); diff --git a/src/resolution/cpp-includers.ts b/src/resolution/cpp-includers.ts index b44439ff8..a9ff3472e 100644 --- a/src/resolution/cpp-includers.ts +++ b/src/resolution/cpp-includers.ts @@ -33,6 +33,8 @@ interface Include { interface Memo { /** Every C / C++ `#include`, by the file name it spells (`a/b.h` → `b.h`). */ byName: Map | null; + /** The same, by the stem of that name (`a/b.h` → `b`). */ + byStem: Map | null; /** Import node id → the indexed file its `#include` names (null: none). */ targets: Map; /** File → the files that include it, directly or through other includes. */ @@ -47,12 +49,17 @@ export function clearCppIncluderMemos(context: ResolutionContext): void { function memoFor(context: ResolutionContext): Memo { let memo = MEMO.get(context); - if (!memo) MEMO.set(context, (memo = { byName: null, targets: new Map(), includers: new Map() })); + if (!memo) MEMO.set(context, (memo = { byName: null, byStem: null, targets: new Map(), includers: new Map() })); return memo; } const isCFamily = (language: string): boolean => language === 'c' || language === 'cpp'; const fileName = (filePath: string): string => filePath.slice(Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) + 1); +const stemOf = (name: string): string => { + const dot = name.lastIndexOf('.'); + return dot > 0 ? name.slice(0, dot) : name; +}; +const dirOf = (filePath: string): string => filePath.slice(0, filePath.lastIndexOf('/') + 1); const toInclude = (n: Pick): Include => ({ id: n.id, spec: n.name, file: n.filePath, language: n.language, line: n.startLine, column: n.startColumn }); @@ -91,6 +98,37 @@ function includesByName(context: ResolutionContext, memo: Memo): Map { + if (memo.byStem) return memo.byStem; + const byStem = new Map(); + for (const [name, includes] of includesByName(context, memo)) { + const stem = stemOf(name); + const list = byStem.get(stem); + if (list) list.push(...includes); + else byStem.set(stem, [...includes]); + } + memo.byStem = byStem; + return byStem; +} + +/** + * The headers in `filePath`'s folder named like it (`field_def.c` → + * `field_def.h`) that it includes, directly or through other headers. upb's + * reflection/field_def.c includes only reflection/internal/field_def.h, which + * reaches the reflection/field_def.h it implements through def.h. + */ +export function cppIncludedHeadersBeside(filePath: string, context: ResolutionContext): string[] { + const memo = memoFor(context); + const dir = dirOf(filePath); + const found: string[] = []; + for (const include of includesByStem(context, memo).get(stemOf(fileName(filePath))) ?? []) { + const target = includedFile(include, context, memo); + if (!target || target === filePath || found.includes(target) || dirOf(target) !== dir) continue; + if (cppIncluders(target, context).has(filePath)) found.push(target); + } + return found; +} + /** The indexed file the `#include` an import node records names, or null. */ export function cppIncludedFile(include: Node, context: ResolutionContext): string | null { return include.kind === 'import' && isCFamily(include.language) ? includedFile(toInclude(include), context, memoFor(context)) : null; diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 72b27f3f9..3534074b9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -18,7 +18,7 @@ import { cppAliasedClass, cppAliasedTypeName, cppAliasedTypeWritten, cppDeclares import { clearCppNamespaceMemos, cppMacroNamespaceFrames, cppMacroNamespaceHeads, cppNameMacros, cppNamespaceAliases } from './cpp-namespaces'; import { clearCppRawStringMemos, cppLiteralsBlanked, cppNonCodeLines } from './cpp-raw-strings'; import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; -import { cppIncludedFile, cppIncluders } from './cpp-includers'; +import { cppIncludedFile, cppIncludedHeadersBeside, cppIncluders } from './cpp-includers'; import { isCFeatureVariant, isOffByDefault } from './c-feature-variants'; import { isTestPath } from '../search/query-utils'; import { isMinifiedContent } from '../extraction/generated-detection'; @@ -1620,7 +1620,10 @@ const CPP_STEM_HEADERS = new WeakMap>() * definitions implement. One beside it, when it includes one, is the only * such header: protobuf's pyext/message.cc implements pyext/message.h, not * the google/protobuf/message.h it also includes; fmt's src/format.cc, with - * none beside it, implements include/fmt/format.h. + * none beside it, implements include/fmt/format.h. It may include that one + * through other headers only: upb's reflection/field_def.c includes + * reflection/internal/field_def.h, which reaches reflection/field_def.h + * through def.h (cppIncludedHeadersBeside). */ function cppSameStemHeaders(filePath: string, context: ResolutionContext): string[] { let memo = CPP_STEM_HEADERS.get(context); @@ -1635,7 +1638,8 @@ function cppSameStemHeaders(filePath: string, context: ResolutionContext): strin if (header && header !== filePath && fileStem(header) === stem) found.push(header); } const dir = filePath.slice(0, filePath.lastIndexOf('/') + 1); - const beside = found.filter((header) => header.slice(0, header.lastIndexOf('/') + 1) === dir); + let beside = found.filter((header) => header.slice(0, header.lastIndexOf('/') + 1) === dir); + if (beside.length === 0) beside = cppIncludedHeadersBeside(filePath, context); headers = beside.length > 0 ? beside : found; memo.set(filePath, headers); } From 036832d3a7c2f3f3a6242ce7fb39835afe250312 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:07:22 +0000 Subject: [PATCH 3/8] fix(extraction): a build folder inside src/ is indexed for its Rust files too (#2695) A folder named build below src/ was indexed for its TypeScript, JSX, Vue, Svelte, Astro, Go and C# files, but not Rust's. dioxus's CLI keeps its whole build pipeline in packages/cli/src/build/, gleam's compiler its project and package compilers in compiler-core/src/build/, and tauri-plugin its build-script helpers in src/build/. Cargo writes its output to target/, so .rs files in such a folder are hand-written, and they are now indexed as well. Rust waited on its name matching: indexing dioxus's src/build/patch.rs, which declares a private type Result, moved 158 Result references off the crate's own alias. A private item now stays in its own module, so those references keep their target. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- README.md | 2 +- __tests__/src-build-folders.test.ts | 23 +++++++++++-------- .../docs/getting-started/configuration.md | 2 +- src/extraction/index.ts | 14 ++++------- 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d7e1d080..01d2414bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -204,7 +204,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a call to a method whose name contains a `$` now links to that method through its receiver's type, like any other method call: RxJS-style `this.organizationService.organizations$(userId)`, or vscode's `this._proxy.$acceptModelChanged(…)` on the interface its RPC proxy is declared with. Before, such a call linked nothing, however well its receiver was typed. Re-index JavaScript and TypeScript projects after upgrading. - In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading. - `codegraph sync` and the file watcher now relink an import that reaches a module through a barrel file passing it on under another name, like `import { MatxLoading } from './components'` when `components/index.js` says `export { default as MatxLoading } from './MatxLoading'`, once an edit gives that module what is imported. Before, the import and every call through it stayed unlinked, or stayed on something else in the project with the same name, until the importing file itself changed or the project was indexed again. -- A folder named `build` inside `src/` is now indexed for its TypeScript, JSX, Vue, Svelte, Astro, Go and C# files, so a feature or package kept there, like the `src/build/` pages of luci-go's milo or Go's own `src/go/build` package, shows up in `codegraph_explore`, and the routes that lazily load its pages link to them. Before, every folder named `build` was skipped as build output. The JavaScript, declaration and other files such a folder holds are still left out, since a build may have written them, and so is every `build` folder outside `src/`. +- A folder named `build` inside `src/` is now indexed for its TypeScript, JSX, Vue, Svelte, Astro, Go, C# and Rust files, so a feature or package kept there, like the `src/build/` pages of luci-go's milo, Go's own `src/go/build` package or the `src/build/` module of dioxus's CLI, shows up in `codegraph_explore`, and the routes that lazily load its pages link to them. Before, every folder named `build` was skipped as build output. The JavaScript, declaration and other files such a folder holds are still left out, since a build may have written them, and so is every `build` folder outside `src/`. - On Windows, a Swift call written after `try!`, like `try! Message2(data: data)` or `try! Settings.Defaults.load()`, now links to what it calls, as it already did on macOS and Linux. Before, the `!` was read as a logical not, so the call linked to nothing, and a call made through a type lost the type it was made on. Re-index Swift projects on Windows after upgrading. - In JavaScript and TypeScript, a barrel's `export { A, B as C } from './x'` now links each name it passes on to what `./x` exports, following barrels that pass it on in turn. Before, each name was looked up by itself and could link to a same-named symbol elsewhere in the project, such as another folder's `Provider`, and a name passed on from a package, like `export { debounce } from 'lodash'`, could link to a project function of that name. Re-index JavaScript and TypeScript projects after upgrading. - In Swift, a call written after `!` or another prefix operator, or chained after `try` or `await`, now links to what it calls. `if !isReady()` links to `isReady`, `!store.contains(id)` and `try! JSONDecoder().decode(…)` link to the method of the type they're called on rather than to whichever `contains` or `decode` came first, and `!Settings.Defaults.isOn()` lands on the type it names. Re-index Swift projects after upgrading. diff --git a/README.md b/README.md index 98021f51f..baac93178 100644 --- a/README.md +++ b/README.md @@ -652,7 +652,7 @@ What it skips out of the box: [supported stack](#supported-languages) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. A `build` folder inside `src/` is the exception: no build step writes TypeScript, JSX, Vue, - Svelte, Astro, Go or C# files into one, so those are indexed from it, + Svelte, Astro, Go, C# or Rust files into one, so those are indexed from it, and a Java, Kotlin or Scala package named `build` is indexed whole. - **Anything in your `.gitignore`** — honored in git repos via git, and in non-git projects by reading `.gitignore` directly (root and nested). diff --git a/__tests__/src-build-folders.test.ts b/__tests__/src-build-folders.test.ts index 1ccf5ff16..166c95b2d 100644 --- a/__tests__/src-build-folders.test.ts +++ b/__tests__/src-build-folders.test.ts @@ -6,12 +6,12 @@ * was indexed and those routes linked nothing. * * Such a folder is now walked and its hand-written source (TypeScript, JSX, - * Vue, Svelte, Astro, and Go and C#, as in Go's `src/go/build` or a .NET - * solution's `src/Build/` project) indexed, while what a build step writes there — - * JavaScript, declarations, CSS, HTML, maps, a frozen app's Python, CMake's - * C — stays out, as does every `build/` folder outside `src/`. Both - * enumeration paths, the git-status sync and the watcher share the rule - * (#1728). + * Vue, Svelte, Astro, and Go, C# and Rust, as in Go's `src/go/build`, a .NET + * solution's `src/Build/` project or dioxus's `src/build/`) indexed, while + * what a build step writes there — JavaScript, declarations, CSS, HTML, maps, + * a frozen app's Python, CMake's C — stays out, as does every `build/` folder + * outside `src/`. Both enumeration paths, the git-status sync and the watcher + * share the rule (#1728). */ import { describe, it, expect, afterEach, vi } from 'vitest'; import { execFileSync } from 'child_process'; @@ -71,9 +71,10 @@ describe('the scope of a build folder below src', () => { 'src/routes/build/+page.svelte', 'src/pages/build/BuildPage.jsx', 'src/content/build/Card.astro', - // Go and C# toolchains write their output elsewhere. + // Go, C# and Rust toolchains write their output elsewhere. 'src/go/build/build.go', 'tools/src/Build/BuildManager.cs', + 'crates/cli/src/build/mod.rs', // A JVM package named `build` keeps every file, as before (#1642). 'src/main/java/com/acme/build/Script.js', ]; @@ -94,8 +95,6 @@ describe('the scope of a build folder below src', () => { 'src/build/CMakeFiles/feature_tests.c', 'src/build/CMakeFiles/feature_tests.cxx', 'src/build/routes.php', - // Rust waits until its name matching keeps a private item to its module. - 'crates/cli/src/build/mod.rs', // The other defaults still apply inside such a folder. 'src/build/node_modules/pkg/index.ts', 'src/build/dist/index.ts', @@ -179,17 +178,21 @@ describe('the scope of a build folder below src', () => { }); describe('a module kept in a build folder below src', () => { - it('is indexed for Go and C#', async () => { + it('is indexed for Go, C# and Rust', async () => { const root = tempDir(); write(root, { 'src/go/build/build.go': 'package build\n\nfunc Import(path string) error {\n\treturn nil\n}\n', 'tools/src/Build/BuildManager.cs': 'namespace Tools.Build\n{\n public class BuildManager\n {\n public void Run() { }\n }\n}\n', + 'crates/cli/Cargo.toml': '[package]\nname = "cli"\nversion = "0.1.0"\n', + 'crates/cli/src/main.rs': 'mod build;\n\nfn main() {\n build::BuildRequest::new().run();\n}\n', + 'crates/cli/src/build/mod.rs': 'pub struct BuildRequest;\n\nimpl BuildRequest {\n pub fn new() -> Self {\n BuildRequest\n }\n\n pub fn run(&self) {}\n}\n', }); const cg = await CodeGraph.init(root, { index: true }); graphs.push(cg); const names = (file: string) => cg.getNodesInFile(file).map((n) => n.name); expect(names('src/go/build/build.go')).toContain('Import'); expect(names('tools/src/Build/BuildManager.cs')).toEqual(expect.arrayContaining(['BuildManager', 'Run'])); + expect(names('crates/cli/src/build/mod.rs')).toEqual(expect.arrayContaining(['BuildRequest', 'new', 'run'])); }, 60_000); }); diff --git a/site/src/content/docs/getting-started/configuration.md b/site/src/content/docs/getting-started/configuration.md index f8a4a9427..400d6204e 100644 --- a/site/src/content/docs/getting-started/configuration.md +++ b/site/src/content/docs/getting-started/configuration.md @@ -7,7 +7,7 @@ Next to none — CodeGraph is **zero-config by default**, with nothing to write ## What it skips out of the box -- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. A `build` folder inside `src/` is the exception: no build step writes TypeScript, JSX, Vue, Svelte, Astro, Go or C# files into one, so those are indexed from it, and a Java, Kotlin or Scala package named `build` is indexed whole. +- **Dependency, build, and cache directories** — `node_modules`, `vendor`, `dist`, `build`, `target`, `.venv`, `Pods`, `.next`, and the like across every [supported stack](/codegraph/reference/languages/) — so the graph is your code, not third-party noise. This holds even with no `.gitignore`. A `build` folder inside `src/` is the exception: no build step writes TypeScript, JSX, Vue, Svelte, Astro, Go, C# or Rust files into one, so those are indexed from it, and a Java, Kotlin or Scala package named `build` is indexed whole. - **Anything in your `.gitignore`** — honored in git repos via git, and in non-git projects by reading `.gitignore` directly (root and nested). - **Files larger than 1 MB** — generated bundles, minified JS, vendored blobs. diff --git a/src/extraction/index.ts b/src/extraction/index.ts index 7c1b49a03..037a40874 100644 --- a/src/extraction/index.ts +++ b/src/extraction/index.ts @@ -300,16 +300,12 @@ const DEFAULT_IGNORE_PATTERNS: string[] = [ * Source files no compiler or bundler writes into a build folder: TypeScript * (with its module and ArkTS variants), JSX, and Vue, Svelte and Astro * components, whose builds emit JavaScript, declarations, CSS, HTML and - * source maps instead; and Go and C#, whose toolchains write elsewhere (Go's - * binaries, .NET's `bin/` and `obj/`). Python, C and C++, PHP and Java are - * left out: setuptools and frozen apps copy `.py` into `build/`, CMake - * generates C and C++ there, WordPress builds `build/*.php`. Rust waits too, - * though Cargo writes to `target/`: its name matching doesn't yet keep a - * private item to its own module, so dioxus's `src/build/patch.rs`, with a - * private `type Result`, took 158 `Result` references the crate's own - * `pub(crate) type Result` should keep. + * source maps instead; and Go, C# and Rust, whose toolchains write elsewhere + * (Go's binaries, .NET's `bin/` and `obj/`, Cargo's `target/`). Python, C and + * C++, PHP and Java are left out: setuptools and frozen apps copy `.py` into + * `build/`, CMake generates C and C++ there, WordPress builds `build/*.php`. */ -const HAND_WRITTEN_SOURCE = /\.(?:[mc]?ts|tsx|ets|jsx|vue|svelte|astro|go|cs)$/i; +const HAND_WRITTEN_SOURCE = /\.(?:[mc]?ts|tsx|ets|jsx|vue|svelte|astro|go|cs|rs)$/i; /** TypeScript declarations, which `tsc` writes beside its JavaScript. */ const DECLARATION_FILE = /\.d\.(?:[mc]?ts|ets)$/i; /** Matches the JVM packages named `build`, whose whole subtree stays indexed. */ From 3273f20f4730d9d5f77fa9591fbcbac285ed707f Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:16:23 +0000 Subject: [PATCH 4/8] fix(objc): a doc comment inside an @implementation documents the method below it (#2696) Inside an Objective-C @implementation, tree-sitter-objc wraps each method, function and declaration in an implementation_definition of its own, and the comment above it is the wrapper's sibling, not the method's. getPrecedingDocstring looked only at the method's own siblings, so no method, function or static written in an @implementation had its doc comment, while the same comment above an @interface property was read. - tree-sitter-helpers.ts: implementation_definition joins DOCSTRING_WRAPPER_TYPES. Each wraps exactly one item, so the climb cannot hand a comment to a neighbour: a comment above an @synthesize stays its own. - codegraph-kernel docstring.rs: the faithful port's wrapper list gains it too. The kernel does not walk Objective-C, so the entry only keeps the two lists alike. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-implementation-docs.test.ts | 75 ++++++++++++++++++++++ codegraph-kernel/src/docstring.rs | 2 + src/extraction/tree-sitter-helpers.ts | 7 +- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 __tests__/objc-implementation-docs.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 01d2414bd..33efde86b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 ``, 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 Objective-C, and in the C and C++ headers that sit beside it, an enum declared with Foundation's macros, like `typedef NS_ENUM(NSInteger, SDImageCacheType) { … };`, `NS_OPTIONS`, `NS_CLOSED_ENUM`, `NS_ERROR_ENUM` or CoreFoundation's `CF_ENUM` and `CF_OPTIONS`, is now indexed with its values. Before, the parser read the macro as a broken typedef, so these enums were missing (SDWebImage had none at all) and a type alias named after the first value stood in their place. A Swift annotation naming such an enum, like KSCrash's `let unwindMethods: KSBacktraceUnwindMethods`, now links to it. Re-index Objective-C, C and C++ projects after upgrading. +- In Objective-C, the doc comment above a method, function or static written inside an `@implementation` is now read as its description, as the one above an `@interface` property already was. Before, the parser's wrapper around each item of an `@implementation` hid the comment, so search and `codegraph_explore` showed these methods without their documentation. 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` followed by its CSS, or `styled(Base)`, 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 `` 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 `} />`, 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 ``, 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. diff --git a/__tests__/objc-implementation-docs.test.ts b/__tests__/objc-implementation-docs.test.ts new file mode 100644 index 000000000..c4fff6c84 --- /dev/null +++ b/__tests__/objc-implementation-docs.test.ts @@ -0,0 +1,75 @@ +/** + * Inside an Objective-C `@implementation`, the parser wraps each method, + * function and declaration in an `implementation_definition` of its own, and + * the comment above it is that wrapper's sibling, not the method's. So no + * method, function or static written in an `@implementation` had its doc + * comment, while the same comment above an `@interface` property was read. + * The wrapper is now climbed like an `export` statement's. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; + +describe('a doc comment inside an Objective-C @implementation', () => { + 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(`documents the method, function or static below it${label}`, () => { + const source = [ + '@interface Tests : NSObject', + '/** The number of runs. */', + '@property (nonatomic) int runs;', + '@end', + '', + '@implementation Tests', + '', + '/** Flags the callbacks set. */', + 'static struct {', + ' int calls;', + '} g_callbackData;', + '', + '/** Runs it. */', + '- (void)testIt {', + '}', + '', + '// A helper.', + 'static void helper(void) {', + '}', + '', + '/**', + ' * The shared instance.', + ' */', + '+ (instancetype)shared {', + ' return nil;', + '}', + '', + '/** Synthesized. */', + '@synthesize runs = _runs;', + '', + '- (void)undocumented {', + '}', + '', + '@end', + '', + ].join('\n'); + const result = extractFromSource('src/Tests.m', eol(source), 'objc'); + const docs = result.nodes + .filter((n) => n.kind !== 'file' && n.kind !== 'class') + .map((n) => `${n.kind} ${n.qualifiedName}: ${n.docstring ?? '-'}`); + expect(docs).toEqual([ + 'property Tests::runs: The number of runs.', + 'struct Tests::g_callbackData: Flags the callbacks set.', + 'method Tests::testIt: Runs it.', + 'function Tests::helper: A helper.', + 'method Tests::shared: The shared instance.', + 'method Tests::undocumented: -', + ]); + }); + } +}); diff --git a/codegraph-kernel/src/docstring.rs b/codegraph-kernel/src/docstring.rs index 85863d175..54214fb67 100644 --- a/codegraph-kernel/src/docstring.rs +++ b/codegraph-kernel/src/docstring.rs @@ -15,6 +15,8 @@ fn is_wrapper(kind: &str) -> bool { | "variable_declaration" | "variable_declarator" | "ambient_declaration" + // Objective-C, which the kernel does not walk: kept for parity. + | "implementation_definition" ) } diff --git a/src/extraction/tree-sitter-helpers.ts b/src/extraction/tree-sitter-helpers.ts index 37a2d8dc4..132719038 100644 --- a/src/extraction/tree-sitter-helpers.ts +++ b/src/extraction/tree-sitter-helpers.ts @@ -87,8 +87,10 @@ export const BUILTIN_TYPE_NAMES: ReadonlySet = new Set([ * inner node, so before looking for its preceding comment we climb out through * these. Examples: `export class X {}` (export_statement), `@dec\ndef f()` * (decorated_definition), `const f = () => {}` (lexical_declaration → - * variable_declarator). Each wraps exactly one declaration, so climbing can't - * mis-attribute a comment to a sibling. (#780) + * variable_declarator), and each method, function or declaration of an + * Objective-C `@implementation` (implementation_definition). Each wraps + * exactly one declaration, so climbing can't mis-attribute a comment to a + * sibling. (#780) */ const DOCSTRING_WRAPPER_TYPES = new Set([ 'export_statement', // JS/TS: export class/function/const ... @@ -97,6 +99,7 @@ const DOCSTRING_WRAPPER_TYPES = new Set([ 'variable_declaration', // JS/TS: var x = ... 'variable_declarator', // JS/TS: the `x = () => {}` inside the declaration 'ambient_declaration', // TS: declare ... + 'implementation_definition', // ObjC: each method, function or declaration of an @implementation ]); /** From 60b2f1e7bbaada4cc3c0d3ff275fb52e2128516e Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:37:17 +0000 Subject: [PATCH 5/8] fix(rust): a call reaches a project impl on a std type only as Rust would pick it (#2697) A crate can't declare a method on String, Vec, Arc or NonNull, only implement a trait for one, and name matching took those impls for any call of that name. Every String::from("...") in dioxus's CLI went to its impl From for String, tokio's NonNull::from(waiter) calls to its impl From<&Handle> for NonNull, its tests' files.iter() to an internal Storage trait's impl for Vec, and clap's String::from calls to its impl From for OsString. A call into a method of a project impl on a std type now stands only when Rust could pick it. A std trait (From, PartialEq, ...) is implemented for the crate's own type argument, so the call must pass one: built from it (IDI::Install), self in an impl of it, or a name the enclosing function declares with it (fn from(name: Str)). A trait of the project's must be in scope in the calling file: declared, implemented or imported there, or brought in by a glob that may carry it. Otherwise the call is left unresolved, since what it calls is the standard library's. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-foreign-impl-calls.test.ts | 140 +++++++++++++++++++ src/resolution/index.ts | 13 +- src/resolution/name-matcher.ts | 162 ++++++++++++++++++++++ 4 files changed, 312 insertions(+), 4 deletions(-) create mode 100644 __tests__/rust-foreign-impl-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 33efde86b..053c09151 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -210,6 +210,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a barrel's `export { A, B as C } from './x'` now links each name it passes on to what `./x` exports, following barrels that pass it on in turn. Before, each name was looked up by itself and could link to a same-named symbol elsewhere in the project, such as another folder's `Provider`, and a name passed on from a package, like `export { debounce } from 'lodash'`, could link to a project function of that name. Re-index JavaScript and TypeScript projects after upgrading. - In Swift, a call written after `!` or another prefix operator, or chained after `try` or `await`, now links to what it calls. `if !isReady()` links to `isReady`, `!store.contains(id)` and `try! JSONDecoder().decode(…)` link to the method of the type they're called on rather than to whichever `contains` or `decode` came first, and `!Settings.Defaults.isOn()` lands on the type it names. Re-index Swift projects after upgrading. - In Swift, a call on a local created through `try`, `try!`, `try?` or `await`, or with a generic type's constructor, now links to that type's method: after `let storage = try! DiskStorage.Backend(config: config)`, `storage.store(…)` links to `Backend`'s `store`. Before, such a local had no known type, so the call was left unlinked or matched by name to another type's method. Re-index Swift projects after upgrading. +- In Rust, a call like `String::from("…")`, `Vec::from([…])` or `NonNull::from(x)` no longer links to your project's own `impl From for String` (or for `Vec`, `NonNull`, …) unless what it passes is a `YourType`. A method call on a standard-library value, like `files.iter()` or `buf.extend(…)`, links to your trait's implementation for that type only where the trait is imported. Before, every `String::from("…")` call in dioxus's CLI linked to one such conversion, and tokio's `vec.iter()` calls to an internal trait's `Vec` impl. Re-index Rust projects after upgrading. - In Rust, a type alias, struct, enum or function declared without `pub` now links only from its own module and the modules inside it, and a name a `use` brings in from one of your workspace's crates, like `use tokio_test::task::spawn` or `use crate::error::Error`, links only to an item of that crate or of a crate it depends on. Before, a file's `use crate::Result` could link its `Result<…>` types to another module's private `type Result`, as in dioxus's CLI, and a call to an imported function could link to another crate's function of the same name, such as tokio's test `spawn(…)` calls to one in its examples, or to nothing because a private function of that name was in the way. Re-index Rust projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/rust-foreign-impl-calls.test.ts b/__tests__/rust-foreign-impl-calls.test.ts new file mode 100644 index 000000000..5d9256299 --- /dev/null +++ b/__tests__/rust-foreign-impl-calls.test.ts @@ -0,0 +1,140 @@ +/** + * A crate can't declare a method on `String`, `Vec`, `Arc` or `NonNull`, only + * implement a trait for one. A call reaches such an impl only as Rust picks it: + * - a std trait the crate implements for one of its own types (`impl + * From for String`) is what `String::from(x)` calls only when `x` is an + * `IDI`. dioxus's CLI's `String::from("…")` calls all went to that impl, + * tokio's `NonNull::from(waiter)` to its `impl From<&Handle> for NonNull`; + * - a trait of the crate's own (`impl Storage for Vec`) is reached only + * where it is in scope: tokio's tests' `files.iter()` on a `Vec` went to its + * `Storage` impl, in files that never import `Storage`. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-foreign-impl-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "cli"\nversion = "0.1.0"\n', + 'src/main.rs': 'mod cli;\nmod names;\nmod slots;\nmod store;\nmod windows;\n\nfn main() {}\n', + 'src/windows.rs': [ + 'pub enum Idi {', + ' Install,', + '}', + '', + 'impl From for String {', + ' fn from(_e: Idi) -> Self {', + ' String::new()', + ' }', + '}', + '', + 'pub fn label() -> String {', + ' String::from(Idi::Install)', + '}', + '', + ].join('\n'), + 'src/names.rs': [ + 'use std::ffi::OsString;', + '', + 'pub struct Str;', + '', + 'impl From for OsString {', + ' fn from(_name: Str) -> Self {', + ' OsString::new()', + ' }', + '}', + '', + 'pub fn os_name(name: Str) -> OsString {', + ' OsString::from(name)', + '}', + '', + ].join('\n'), + 'src/cli.rs': [ + 'use std::ffi::OsString;', + '', + 'pub fn banner() -> String {', + ' String::from("dx")', + '}', + '', + 'pub fn arg() -> OsString {', + ' OsString::from("--release")', + '}', + '', + ].join('\n'), + 'src/store.rs': [ + 'pub trait Storage {', + ' fn iter(&self) -> usize;', + '}', + '', + 'pub struct Slot;', + '', + 'impl Storage for Vec {', + ' fn iter(&self) -> usize {', + ' self.len()', + ' }', + '}', + '', + ].join('\n'), + // Storage in scope: its `iter`. + 'src/slots.rs': [ + 'use crate::store::{Slot, Storage};', + '', + 'pub fn count(slots: Vec) -> usize {', + ' slots.iter()', + '}', + '', + ].join('\n'), + // No Storage here: a Vec's own `iter`. + 'tests/files.rs': [ + 'pub fn paths(files: Vec) -> usize {', + ' files.iter().count()', + '}', + '', + ].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 }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** What `name`, called in `file`, links to, as `file@line` of each target, sorted. */ +const callsOf = (file: string, name: string): string[] => { + const out: string[] = []; + for (const node of cg.getNodesInFile(file)) { + for (const edge of cg.getOutgoingEdges(node.id)) { + if (edge.kind !== 'calls') continue; + const target = cg.getNode(edge.target)!; + if (target.name === name) out.push(`${target.filePath}@${target.startLine}`); + } + } + return [...new Set(out)].sort(); +}; + +describe('a Rust call into a project impl on a std type', () => { + it('is not a std trait impl unless its argument is the impl\'s own type', () => { + expect(callsOf('src/cli.rs', 'from')).toEqual([]); + }); + + it('reaches it when the argument is that type: built from it, or declared with it', () => { + expect(callsOf('src/windows.rs', 'from')).toEqual(['src/windows.rs@6']); + expect(callsOf('src/names.rs', 'from')).toEqual(['src/names.rs@6']); + }); + + it('reaches a project trait\'s impl only where the trait is in scope', () => { + expect(callsOf('src/slots.rs', 'iter')).toEqual(['src/store.rs@8']); + expect(callsOf('tests/files.rs', 'iter')).toEqual([]); + }); +}); diff --git a/src/resolution/index.ts b/src/resolution/index.ts index a6aeb6f2f..da621ada0 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, GO_TYPE_KINDS, matchGoAssertedCall, isGoAssertedLocal, isGoLocallyBound } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -3441,12 +3441,17 @@ export class ReferenceResolver { /** * A bare Rust name reaches only what is in scope — every strategy's result, * a framework resolver's `Ok(x)` → `struct Ok` construction included (see - * isRustNameInScope). + * isRustNameInScope). And a call, however written, reaches a project impl + * on a std type only as Rust would pick it (isRustForeignImplCallable). */ private gateRustScope(result: ResolvedRef | null, ref: UnresolvedRef): ResolvedRef | null { - if (!result || ref.language !== 'rust' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return result; + if (!result || ref.language !== 'rust') return result; + const bare = /^[A-Za-z_]\w*$/.test(ref.referenceName); + if (!bare && ref.referenceKind !== 'calls') return result; const target = this.nodeById(result.targetNodeId); - return target && !isRustNameInScope(target, ref, this.context) ? null : result; + if (!target) return result; + if (bare && !isRustNameInScope(target, ref, this.context)) return null; + return ref.referenceKind === 'calls' && !isRustForeignImplCallable(target, ref, this.context) ? null : result; } /** Is `filePath` (project-relative) one of the files the index holds? */ diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 3534074b9..88f0fee1d 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -7876,6 +7876,167 @@ export function isRustNameInScope(candidate: Node, ref: UnresolvedRef, context: return uses.names.has(name) || rustGlobCovers(uses, candidate, ref); } +/** + * Std types a crate can implement a trait for but never declare a method on: + * a method the index holds for one is the project's `impl SomeTrait for + * String` (`Vec`, `Arc`, `NonNull`, …). + */ +const RUST_STD_TYPES = new Set([ + 'String', 'str', 'Vec', 'VecDeque', 'HashMap', 'HashSet', 'BTreeMap', 'BTreeSet', 'BinaryHeap', 'LinkedList', + 'Box', 'Rc', 'Arc', 'Weak', 'Cow', 'Cell', 'RefCell', 'Mutex', 'RwLock', 'Option', 'Result', 'PathBuf', 'Path', + 'OsString', 'OsStr', 'CString', 'CStr', 'NonNull', 'Pin', 'Duration', 'Instant', 'SystemTime', 'PhantomData', + 'u8', 'u16', 'u32', 'u64', 'u128', 'usize', 'i8', 'i16', 'i32', 'i64', 'i128', 'isize', 'f32', 'f64', 'bool', 'char', +]); +const RUST_DECLARED_TYPE_KINDS = new Set(['struct', 'enum', 'union', 'type_alias', 'trait']); + +interface RustImplHeader { + /** The trait's name, the last segment of its path (`linked_list::Link` → `Link`). */ + trait: string; + /** The type names its arguments write (`From<&'a Handle>` → `Handle`). */ + traitArgs: string[]; + /** The implementing type's name (`NonNull` → `NonNull`). */ + self: string; +} + +const RUST_IMPL_HEADERS = new WeakMap>(); + +/** `impl<'a, T> From<&'a Handle> for NonNull where … {` → its trait and type; null for an inherent impl. */ +function parseRustImplHeader(text: string): RustImplHeader | null { + let rest = text.replace(/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?impl\b/, '').replace(/\{[\s\S]*$/, '').replace(/\bwhere\b[\s\S]*$/, '').trim(); + // The impl's own generics, `<'a, T: Into>`. + if (rest.startsWith('<')) { + let depth = 0; + let i = 0; + for (; i < rest.length; i++) { + if (rest[i] === '<') depth++; + else if (rest[i] === '>' && --depth === 0) break; + } + rest = rest.slice(i + 1).trim(); + } + // ` for ` outside the angle brackets parts the trait from the type. + let depth = 0; + let split = -1; + for (let i = 0; i < rest.length && split < 0; i++) { + if (rest[i] === '<') depth++; + else if (rest[i] === '>') depth--; + else if (depth === 0 && /\s/.test(rest[i]!) && /^for\s/.test(rest.slice(i + 1))) split = i; + } + if (split < 0) return null; + const traitPart = rest.slice(0, split).trim().replace(/^!/, ''); + const head = (part: string): string => + /([A-Za-z_]\w*)\s*(?:<|$)/.exec(part.replace(/^(?:&\s*(?:'\w+\s+)?(?:mut\s+)?|dyn\s+)+/, '').trim())?.[1] ?? ''; + const open = traitPart.indexOf('<'); + const traitArgs = open < 0 ? [] : [...traitPart.slice(open + 1).matchAll(/(? m[1]!).filter((id) => id !== 'mut' && id !== 'dyn'); + return { trait: head(open < 0 ? traitPart : traitPart.slice(0, open)), traitArgs, self: head(rest.slice(split + 5)) }; +} + +/** The `impl Trait<…> for Type<…>` a Rust method is declared in: null for an inherent impl, or none. */ +function rustTraitImplHeader(method: Node, context: ResolutionContext): RustImplHeader | null { + let memo = RUST_IMPL_HEADERS.get(context); + if (!memo) RUST_IMPL_HEADERS.set(context, (memo = new Map())); + const hit = memo.get(method.id); + if (hit !== undefined) return hit; + let header: RustImplHeader | null = null; + const lines = context.getFileLines?.(method.filePath) ?? context.readFile(method.filePath)?.split(/\r?\n/) ?? []; + for (let i = method.startLine - 2; i >= 0; i--) { + const line = lines[i] ?? ''; + if (/^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?impl\b/.test(line)) { + let text = line.replace(/\/\/.*$/, ''); + for (let j = i + 1; !text.includes('{') && j < Math.min(lines.length, i + 8); j++) text += ` ${(lines[j] ?? '').replace(/\/\/.*$/, '')}`; + header = parseRustImplHeader(text); + break; + } + // A top-level item above the method means it was not inside an impl. + if (/^(?:pub(?:\([^)]*\))?\s+)?(?:fn|struct|enum|mod|trait|const|static|type)\b/.test(line)) break; + } + memo.set(method.id, header); + return header; +} + +function isRustProjectNamed(name: string, kinds: ReadonlySet, context: ResolutionContext): boolean { + return context.getNodesByName(name).some((n) => n.language === 'rust' && kinds.has(n.kind)); +} + +const RUST_TRAIT_KIND = new Set(['trait']); + +/** + * Whether a call's first argument is evidently a `type`: a value built through + * it (`IDI::Install`, `Str::from(…)`), `self` in an impl of it, or a name the + * enclosing function declares with it (`fn from(name: Str)`, `let s: Str`). + */ +function rustFirstArgIs(type: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; + const method = ref.referenceName.slice(Math.max(ref.referenceName.lastIndexOf(':'), ref.referenceName.lastIndexOf('.')) + 1); + const text = [(lines[ref.line - 1] ?? '').slice(Math.max(0, ref.column)), ...lines.slice(ref.line, ref.line + 4)].join(' '); + const open = new RegExp(`\\b${method}\\s*\\(`).exec(text); + // Not where the reference says: nothing to read, so nothing to veto. + if (!open) return true; + let depth = 0; + let end = open.index + open[0].length; + for (; end < text.length; end++) { + const ch = text[end]!; + if ('([{'.includes(ch)) depth++; + else if (')]}'.includes(ch)) { + if (depth === 0) break; + depth--; + } else if (ch === ',' && depth === 0) break; + } + const arg = text.slice(open.index + open[0].length, end).trim().replace(/^(?:&\s*(?:mut\s+)?|\*)+/, '').replace(/\.clone\(\)$/, ''); + if (new RegExp(`^(?:[A-Za-z_]\\w*::)*${type}\\b`).test(arg)) return true; + if (!/^[A-Za-z_]\w*$/.test(arg)) return false; + const declared = new RegExp(`\\b${arg}\\s*:\\s*&?\\s*(?:'\\w+\\s+)?(?:mut\\s+)?(?:[A-Za-z_]\\w*::)*${type}\\b`); + const built = new RegExp(`\\blet\\s+(?:mut\\s+)?${arg}\\s*=\\s*&?\\s*(?:[A-Za-z_]\\w*::)*${type}\\b`); + for (let i = ref.line - 1; i >= 0; i--) { + const line = lines[i] ?? ''; + if (arg === 'self' && /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?impl\b/.test(line)) { + const impl = parseRustImplHeader(`${line} {`); + return (impl?.self ?? /impl\b(?:\s*<[^>]*>)?\s*(?:[A-Za-z_]\w*::)*([A-Za-z_]\w*)/.exec(line)?.[1]) === type; + } + if (arg !== 'self' && (declared.test(line) || built.test(line))) return true; + // Above the enclosing function, nothing more declares it. + if (arg !== 'self' && /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+"[^"]*"\s+)?fn\s/.test(line)) break; + } + return false; +} + +/** Whether the file of `ref` has the project trait `trait` in scope: declared or implemented there, or brought in by a `use`. */ +function isRustTraitInScope(trait: string, ref: UnresolvedRef, target: Node, context: ResolutionContext): boolean { + if (ref.filePath === target.filePath) return true; + const traits = context.getNodesByName(trait).filter((n) => n.kind === 'trait' && n.language === 'rust'); + if (traits.some((t) => t.filePath === ref.filePath)) return true; + const uses = rustUsesOf(ref.filePath, context); + if (uses.names.has(trait)) return true; + // A glob over a prelude or a crate's root may hand it on: it can't be told apart. + const crates = rustProjectCrates(context); + if ([...uses.globs].some((g) => g === 'prelude' || g === 'crate' || crates.has(g))) return true; + return traits.some((t) => rustGlobCovers(uses, t, ref)); +} + +/** + * Whether a Rust call can reach this method of a project `impl` on a std type, + * as Rust picks one. A crate implements only a trait for `String`, `Vec`, + * `Arc` or `NonNull`, never a method of its own: + * - a std trait, which it can implement only for one of its own types + * (`impl From for String`): `String::from(x)` is that impl's only when + * `x` is an `IDI`. dioxus's `String::from("…")` calls all went to it, + * tokio's `NonNull::from(waiter)` to its `impl From<&Handle> for NonNull`; + * - a trait of the project's (`impl Link for Arc`), reached only + * where the trait is in scope: tokio's `Arc::from_raw(ptr)` in `util/wake.rs` + * is std's own, its tests' `files.iter()` a `Vec`'s, not a `Storage` impl's. + */ +export function isRustForeignImplCallable(target: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (target.language !== 'rust' || target.kind !== 'method') return true; + const cut = target.qualifiedName.lastIndexOf('::'); + const owner = cut < 0 ? '' : target.qualifiedName.slice(0, cut).split('::').pop()!; + if (!RUST_STD_TYPES.has(owner) || isRustProjectNamed(owner, RUST_DECLARED_TYPE_KINDS, context)) return true; + const impl = rustTraitImplHeader(target, context); + if (!impl || impl.self !== owner || impl.trait === '') return true; + if (isRustProjectNamed(impl.trait, RUST_TRAIT_KIND, context)) return isRustTraitInScope(impl.trait, ref, target, context); + const local = impl.traitArgs.find((t) => isRustProjectNamed(t, RUST_DECLARED_TYPE_KINDS, context)); + return local === undefined || rustFirstArgIs(local, ref, context); +} + /** Whether `type` is a Rust enum that declares `variant` (#2328). */ function declaresRustVariant(type: Node, variant: string, context: ResolutionContext): boolean { if (type.kind !== 'enum') return false; @@ -13394,6 +13555,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUST_CRATES.delete(context); RUST_DEPENDENCIES.delete(context); RUST_CRATE_GRAPHS.delete(context); + RUST_IMPL_HEADERS.delete(context); LEXICAL_SCOPE_MEMO.delete(context); KOTLIN_LAMBDA_RECEIVERS.delete(context); SCALA_IMPORTED_SUPERS.delete(context); From ed8957710f43e9865977ffec6226c6a26c0baca5 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:43:14 +0000 Subject: [PATCH 6/8] fix(objc): a file-scope constant or global is indexed, in an @implementation too (#2699) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Objective-C read a file-scope declaration through the generic variable fallback, which takes only a declarator that is a bare name (`int count;`). Its most common globals were never indexed: `NSString *const SDWebImageErrorDomain = @"…";`, `static const NSInteger kMaxRetries = 3;`, `static dispatch_queue_t queues[4];`. A declaration written inside an @implementation, file-scope C in the language, was not read at all, because the class counts as an enclosing scope. - tree-sitter.ts: Objective-C takes C's declarator branch in extractVariable (initialized, pointer and array declarators, with the initializer as the signature), keeping the bare names it always read. A declaration whose parent is an implementation_definition takes the variable branch; its globals are qualified under the class, as the C functions written there already are. A block or function-pointer variable reads as a prototype, as in C, and stays out. - c-cpp.ts: isConstDeclaration, C's isConst, is shared, and objc.ts uses it, so `static const NSInteger kMaxRetries` is a constant. - name-matcher.ts: a global that a .m or .mm file defines joins the C types that #2690 made file-local (OBJC_FILE_LOCAL_KINDS). A new isObjcFileLocalElsewhere drops such a candidate in the exact-match and fuzzy chains, before one is picked, and isVisibleAcrossFiles applies it to the pick. Before the pick, so that a header's `extern NSString *MJConfigFile;` stands for the definition in variables.m. Rejecting only the pick left Hammerspoon's MJConfigFile references unresolved. Without the rule, React Native's completionHandler(nil, image) block-parameter calls reached a completionHandler in another .mm, and #include a .mm's vector. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/objc-file-scope-variables.test.ts | 153 ++++++++++++++++++++ __tests__/objc-implementation-docs.test.ts | 1 + src/extraction/languages/c-cpp.ts | 19 ++- src/extraction/languages/objc.ts | 3 + src/extraction/tree-sitter.ts | 19 ++- src/resolution/name-matcher.ts | 36 ++++- 7 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 __tests__/objc-file-scope-variables.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 053c09151..717d34ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -185,6 +185,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 Objective-C, and in the C and C++ headers that sit beside it, an enum declared with Foundation's macros, like `typedef NS_ENUM(NSInteger, SDImageCacheType) { … };`, `NS_OPTIONS`, `NS_CLOSED_ENUM`, `NS_ERROR_ENUM` or CoreFoundation's `CF_ENUM` and `CF_OPTIONS`, is now indexed with its values. Before, the parser read the macro as a broken typedef, so these enums were missing (SDWebImage had none at all) and a type alias named after the first value stood in their place. A Swift annotation naming such an enum, like KSCrash's `let unwindMethods: KSBacktraceUnwindMethods`, now links to it. Re-index Objective-C, C and C++ projects after upgrading. - In Objective-C, the doc comment above a method, function or static written inside an `@implementation` is now read as its description, as the one above an `@interface` property already was. Before, the parser's wrapper around each item of an `@implementation` hid the comment, so search and `codegraph_explore` showed these methods without their documentation. Re-index Objective-C projects after upgrading. +- In Objective-C, a file-scope constant or global, like `NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain";` or `static const NSInteger kMaxRetries = 3;`, is now indexed with its value, and so is one written inside an `@implementation`. Before, only a global declared with a bare name, like `int count;`, was, so error domains, notification names, keys and shared queues were missing from search and `codegraph_explore`. A global that a `.m` file defines is reached from other files through the header that declares it, and a call of a block parameter, like `completionHandler(nil, image)`, no longer links to a namesake that another `.m` file keeps. 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` followed by its CSS, or `styled(Base)`, 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 `` 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 `} />`, 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 ``, 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. diff --git a/__tests__/objc-file-scope-variables.test.ts b/__tests__/objc-file-scope-variables.test.ts new file mode 100644 index 000000000..b7159a302 --- /dev/null +++ b/__tests__/objc-file-scope-variables.test.ts @@ -0,0 +1,153 @@ +/** + * Objective-C read a file-scope variable only when its declarator was a bare + * name (`int plain;`), so its most common globals were never indexed: + * + * NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain"; + * static const NSInteger kMaxRetries = 3; + * @implementation SDImageCache + * static NSString *const kKey = @"…"; // file-scope C inside @implementation + * + * Objective-C now reads a declaration's declarators as C does (initialized, + * pointer and array ones), keeps its bare names, and reads a declaration + * written inside an `@implementation` too. A global that a `.m` file defines + * is that file's, as C makes it: another file reaches it through the header + * that declares it, and never through a namesake in another `.m`, like a + * block parameter called `completionHandler(…)`. + */ +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'; + +describe('an Objective-C file-scope variable', () => { + 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(`is indexed with its initializer, at file scope and inside an @implementation${label}`, () => { + const source = [ + '#import ', + '', + '/// The error domain.', + 'NSString *const SDWebImageErrorDomain = @"SDWebImageErrorDomain";', + 'static const NSInteger kMaxRetries = 3;', + 'static int counter = 0;', + 'int plain;', + 'static dispatch_queue_t queues[4];', + 'extern NSString *const SDExternDecl;', + 'static void (^gHandler)(void) = ^{ };', + '', + '@implementation SDImageCache', + 'static NSString *const kKey = @"key";', + '- (void)run', + '{', + '\tint local = 1;', + '\t(void)local;', + '}', + '@end', + '', + ].join('\n'); + const result = extractFromSource('src/SDImageCache.m', eol(source), 'objc'); + const symbols = result.nodes + .filter((n) => n.kind !== 'file' && n.kind !== 'import') + .map((n) => `${n.kind} ${n.qualifiedName}${n.signature ? ` ${n.signature}` : ''}`); + // A block or function-pointer variable reads as a prototype, as in C. + expect(symbols).toEqual([ + 'variable SDWebImageErrorDomain = @"SDWebImageErrorDomain"', + 'constant kMaxRetries = 3', + 'variable counter = 0', + 'variable plain', + 'variable queues', + 'variable SDExternDecl', + 'class SDImageCache', + 'variable SDImageCache::kKey = @"key"', + 'method SDImageCache::run', + ]); + expect(result.nodes.find((n) => n.name === 'SDWebImageErrorDomain')?.docstring).toBe('The error domain.'); + }); + } +}); + +describe('an indexed Objective-C project with globals', () => { + let root = ''; + let cg: CodeGraph | undefined; + + afterEach(() => { + cg?.destroy(); + cg = undefined; + if (root) fs.rmSync(root, { recursive: true, force: true }); + root = ''; + }); + + /** Index `files` and return what a method's edges, `contains` aside, reach. */ + async function indexProject(files: Record): Promise<(method: string) => string[]> { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-objc-globals-')); + 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); + } + const graph = (cg = await CodeGraph.init(root, { index: true })); + return (method: string) => { + const node = graph.getNodesByName(method).find((n) => n.kind === 'method')!; + expect(node, method).toBeDefined(); + return graph + .getOutgoingEdges(node.id) + .filter((e) => e.kind !== 'contains') + .map((e) => { + const t = graph.getNode(e.target)!; + return `${e.kind} ${t.kind} ${t.name} ${t.filePath}`; + }); + }; + } + + it('reaches a global through the header that declares it', async () => { + const targetsOf = await indexProject({ + 'Hammerspoon/variables.h': ['#import ', '', 'extern NSString *MJConfigFile;', ''].join('\n'), + 'Hammerspoon/variables.m': ['#import "variables.h"', '', 'NSString *MJConfigFile = @"~/.hammerspoon/init.lua";', ''].join('\n'), + 'Hammerspoon/MJConfigUtils.m': [ + '#import "variables.h"', + '', + '@implementation MJConfigUtils', + '- (NSString *)configPath', + '{', + '\treturn [MJConfigFile stringByStandardizingPath];', + '}', + '@end', + '', + ].join('\n'), + }); + expect(targetsOf('configPath')).toEqual(['references variable MJConfigFile Hammerspoon/variables.h']); + }, 60_000); + + it('never reaches a global another .m file defines', async () => { + const targetsOf = await indexProject({ + 'Libraries/RCTPushNotificationManager.m': [ + '#import ', + '', + 'static NSString *completionHandler = nil;', + '', + ].join('\n'), + 'Libraries/RCTBundleAssetImageLoader.m': [ + '#import ', + '', + '@implementation RCTBundleAssetImageLoader', + '- (void)loadWithCompletion:(void (^)(id image))completionHandler', + '{', + '\tcompletionHandler(nil);', + '}', + '@end', + '', + ].join('\n'), + }); + // The call is the block parameter's. + expect(targetsOf('loadWithCompletion:')).toEqual([]); + }, 60_000); +}); diff --git a/__tests__/objc-implementation-docs.test.ts b/__tests__/objc-implementation-docs.test.ts index c4fff6c84..d7aa866db 100644 --- a/__tests__/objc-implementation-docs.test.ts +++ b/__tests__/objc-implementation-docs.test.ts @@ -65,6 +65,7 @@ describe('a doc comment inside an Objective-C @implementation', () => { expect(docs).toEqual([ 'property Tests::runs: The number of runs.', 'struct Tests::g_callbackData: Flags the callbacks set.', + 'variable Tests::g_callbackData: Flags the callbacks set.', 'method Tests::testIt: Runs it.', 'function Tests::helper: A helper.', 'method Tests::shared: The shared instance.', diff --git a/src/extraction/languages/c-cpp.ts b/src/extraction/languages/c-cpp.ts index 8e75f9454..d2f211ec3 100644 --- a/src/extraction/languages/c-cpp.ts +++ b/src/extraction/languages/c-cpp.ts @@ -32,6 +32,17 @@ export function cDeclaratorIdentifier(node: SyntaxNode | null): SyntaxNode | nul return null; } +/** + * Whether a C or Objective-C declaration is a constant: a `const` or + * `static const` file-scope declaration carries a `type_qualifier` child + * reading "const". Extracted as `constant`, plain globals as `variable`. A + * `const` inside a pointer declarator (`char *const p`) qualifies the + * pointer, and the declaration stays a variable. + */ +export function isConstDeclaration(node: SyntaxNode): boolean { + return node.namedChildren.some((c: SyntaxNode) => c.type === 'type_qualifier' && c.text === 'const'); +} + /** C and C++ specifiers that define a class-like type, or an enum, when they carry a body (C has no `class_specifier`). */ const CLASS_LIKE_SPECIFIERS = new Set(['class_specifier', 'struct_specifier', 'union_specifier', 'enum_specifier']); @@ -431,13 +442,7 @@ export const cExtractor: LanguageExtractor = { nameField: 'declarator', bodyField: 'body', paramsField: 'parameters', - // A `const`/`static const` file-scope declaration carries a `type_qualifier` - // child reading "const" — extract those as `constant`, plain globals as - // `variable`. - isConst: (node) => - node.namedChildren.some( - (c: SyntaxNode) => c.type === 'type_qualifier' && c.text === 'const' - ), + isConst: isConstDeclaration, getReturnType: extractCppReturnType, resolveTypeAliasKind: (node, _source) => { // C typedef: `typedef enum { ... } name;` or `typedef struct { ... } name;` diff --git a/src/extraction/languages/objc.ts b/src/extraction/languages/objc.ts index 88fdc9292..d55588566 100644 --- a/src/extraction/languages/objc.ts +++ b/src/extraction/languages/objc.ts @@ -4,6 +4,7 @@ import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; import { blankLoneMacroLines, definingDeclaration, + isConstDeclaration, OBJC_LONE_MACROS, rewriteFoundationEnumMacros, unnamedTypeVariableName, @@ -129,6 +130,8 @@ export const objcExtractor: LanguageExtractor = { bodyField: 'body', paramsField: 'parameters', getReturnType: extractObjcReturnType, + // `static const NSInteger kMaxRetries = 3;` is a constant, as in C. + isConst: isConstDeclaration, // A method is named by its selector. An unnamed struct, union or enum // defined in a declaration's type takes the first variable's name, as in C: // `static struct { … } placeholder;` is the struct `placeholder`. diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index cf07a4080..27d222c32 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1515,9 +1515,14 @@ export class TreeSitterExtractor { // languages) keep almost exclusively inside a class/module. A Ruby `CONST = // …` has a `constant`-typed LHS; other languages don't put one here, so this // is effectively Ruby-only and doesn't disturb their class-internal locals. + // An Objective-C declaration written in an `@implementation` + // (`static NSString *const kKey = @"…";`) is file-scope C, though it sits + // between `@implementation` and `@end`. else if ( this.extractor.variableTypes.includes(nodeType) && - (!this.isInsideClassLikeNode() || this.isClassScopeConstantAssignment(node)) + (!this.isInsideClassLikeNode() || + this.isClassScopeConstantAssignment(node) || + (this.language === 'objc' && node.parent?.type === 'implementation_definition')) ) { // C, C++ and Objective-C: a class, struct, union or enum defined in the // declaration's type (`struct Foo { … } foo;`, `static struct { … } @@ -3709,8 +3714,8 @@ export class TreeSitterExtractor { const initSignature = initValue ? `= ${initValue}${initValue.length >= 100 ? '...' : ''}` : undefined; this.createNode(kind, target.name, nameNode, { docstring, signature: initSignature, isExported }); }); - } else if (this.language === 'c') { - // C: a `declaration` node's name nests inside the `declarator` field — + } else if (this.language === 'c' || this.language === 'objc') { + // C and Objective-C: a `declaration` node's name nests inside the `declarator` field — // `init_declarator` (with value) or bare/pointer/array declarators (no // value); a `function_declarator` is a prototype, not a variable. The // generic fallback below only finds a *direct* identifier child, which C @@ -3733,11 +3738,15 @@ export class TreeSitterExtractor { // a spurious type-named global for every macro-prefixed prototype in a // header. Those misparses are always bare identifiers; real // consts/tables always carry an initializer. The only legit loss is - // uninitialized scalar globals (`static int g;`). + // uninitialized scalar globals (`static int g;`). Objective-C keeps + // them, as it did before it shared this branch: until then it read + // nothing but bare identifiers, and missed every + // `NSString *const kKey = @"…";`. if ( child.type !== 'init_declarator' && child.type !== 'pointer_declarator' && - child.type !== 'array_declarator' + child.type !== 'array_declarator' && + !(this.language === 'objc' && child.type === 'identifier') ) { continue; } diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 88f0fee1d..ad1d3f1c2 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2002,8 +2002,23 @@ const C_STATIC_MEMO = new WeakMap>(); */ const C_SOURCE_EXT = /\.(c|cc|cpp|cxx|c\+\+|m|mm)$/i; -/** The C types an Objective-C file can define, as opposed to its classes and protocols. */ -const OBJC_C_TYPE_KINDS: ReadonlySet = new Set(['struct', 'union', 'enum', 'type_alias']); +/** + * What an Objective-C source file defines for itself, as C does: its C types + * and its globals, as opposed to its classes and protocols. + */ +const OBJC_FILE_LOCAL_KINDS: ReadonlySet = new Set(['struct', 'union', 'enum', 'type_alias', 'variable', 'constant']); + +/** + * Whether `n` is a C type or global that an Objective-C source file other + * than `ref`'s defines, which no other file can name. It leaves the + * candidates before one is picked, so a global's declaration in a header + * (Hammerspoon's `extern NSString *MJConfigFile;` in variables.h) stands for + * the definition in variables.m. isVisibleAcrossFiles applies the same rule + * to the pipeline's pick. + */ +function isObjcFileLocalElsewhere(n: Node, ref: UnresolvedRef): boolean { + return n.language === 'objc' && n.filePath !== ref.filePath && OBJC_FILE_LOCAL_KINDS.has(n.kind) && C_SOURCE_EXT.test(n.filePath); +} /** Per context: a C/C++ type's qualified name → its declarations. */ const CPP_TYPE_DECLS = new WeakMap>(); @@ -3145,8 +3160,9 @@ function isGoNameVisible(candidate: Node, ref: UnresolvedRef): boolean { * whichever USB class file ranked first. So is what a source file's * unnamed namespace declares, and a type only source files declare, with * its members (isCppTranslationUnitLocal). - * - **Objective-C**: a struct, union, enum or typedef a `.m` / `.mm` source - * file defines is local to that file, as in C. A class is not. + * - **Objective-C**: a struct, union, enum, typedef or global a `.m` / `.mm` + * source file defines is local to that file, as in C; other files reach a + * global through its header's `extern` declaration. A class is not. * - **Kotlin, Java, C#, Swift, Scala, Dart, PHP**: `private` is class- or * file-local. An Android `editor.apply()` resolved onto an unrelated class's * `private fun apply`. @@ -3220,8 +3236,14 @@ export function isVisibleAcrossFiles(candidate: Node, ref: UnresolvedRef, contex // An Objective-C source file is a translation unit too: a C type it defines // is its own. ObjFW's runtime keeps `static struct Lock { … } *locks;` in // synchronized.m, and the AmigaOS `Lock(path, mode)` calls in other files - // make no such struct. (A class is the runtime's, wherever it is written.) - if (lang === 'objc' && OBJC_C_TYPE_KINDS.has(candidate.kind) && C_SOURCE_EXT.test(candidate.filePath)) return false; + // make no such struct. So is a global it defines: another file reaches one + // through the header that declares it (`extern OFStdIOStream *OFStdOut;`), + // whose node stands for it there. React Native's + // `completionHandler(nil, image)` calls a block parameter, not the + // `completionHandler` another .mm file keeps, and `#include ` names + // no `.mm` file's `vector`. (A class is the runtime's, wherever it is + // written.) + if (isObjcFileLocalElsewhere(candidate, ref)) return false; if (lang === 'go') return isGoNameVisible(candidate, ref); if (lang === 'rust') { if (candidate.visibility !== 'private') return true; @@ -8437,6 +8459,7 @@ export function matchByExactName( !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(objcShape === 'super-send' && !isObjcSelfSendTarget(n, ref, context, true)) && + !isObjcFileLocalElsewhere(n, ref) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) && !(vbReceiver && !/^(?:me|mybase|myclass)$/i.test(vbReceiver) && !isVbTypeQualifiedBy(n, vbReceiver, ref.filePath, context)) && !(vbScoped && !isVbMemberInScope(n, ref, context)) && @@ -18146,6 +18169,7 @@ export function matchFuzzy( !(objcShape === 'c-call' && OBJC_MEMBER_KINDS.has(n.kind)) && !(objcShape === 'self-send' && !isObjcSelfSendTarget(n, ref, context)) && !(objcShape === 'super-send' && !isObjcSelfSendTarget(n, ref, context, true)) && + !isObjcFileLocalElsewhere(n, ref) && !(csharpBare && !isCsharpMemberInScope(n, ref, context)) && !(scalaBare && !isScalaMemberInScope(n, ref, context)) && !(rustGoShape && !isRustGoCallTarget(n, rustGoShape)) && From 204315274e7902c713ba3ad61e8df511aa634b10 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 01:52:07 +0000 Subject: [PATCH 7/8] fix(cpp): every name a declaration lists has its type, like `strings_found` in `ByteBuffer symbols_found, strings_found;` (#2698) C++ receiver inference read a declaration with a regex that wants the type right before the name. A declaration that lists several names writes the type once, before the first, so the later names were never read: google/breakpad's `strings_found.Size()` after `ByteBuffer symbols_found, strings_found;` linked nothing, and a later name could take the type of an earlier variable of the same name further up. matchCppListedDeclarator reads such a declaration from its start (specifiers, the type with its template arguments paired, then each declarator with its own pointer and reference marks, qualifiers, annotations, array bounds and initializer) and gives a later name the declaration's type plus its own `*`s and `&`s: `Foo *a, b;` declares one pointer. Names are told apart only by commas at the statement's own depth, so template arguments, a call's or a constructor's arguments, an initializer list or an expression list no name. It runs only where the declarator regex finds nothing on a line, in all three declaration readers: the receiver scan (and its header scan), a class's data members (`Table *index_, *data_;`), and the check that a bare call names a local. A/B on main 036832d3 with current upstream clones: breakpad +30 added, 5 overload retargets wrong to right, 66 guesses now typed; protobuf +97, 1 retarget wrong to right, 9 removed (5 wrong, 4 single-candidate guesses on message classes the C++ index lacks, now like the first name in their list); rocksdb +43, 5 wrong removed; leveldb +4. glog and redis byte-identical. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + .../cpp-listed-declarator-receiver.test.ts | 363 ++++++++++++++++++ src/resolution/name-matcher.ts | 254 +++++++++++- 3 files changed, 614 insertions(+), 4 deletions(-) create mode 100644 __tests__/cpp-listed-declarator-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 717d34ead..6fd53c5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Swift, a call on a local created through `try`, `try!`, `try?` or `await`, or with a generic type's constructor, now links to that type's method: after `let storage = try! DiskStorage.Backend(config: config)`, `storage.store(…)` links to `Backend`'s `store`. Before, such a local had no known type, so the call was left unlinked or matched by name to another type's method. Re-index Swift projects after upgrading. - In Rust, a call like `String::from("…")`, `Vec::from([…])` or `NonNull::from(x)` no longer links to your project's own `impl From for String` (or for `Vec`, `NonNull`, …) unless what it passes is a `YourType`. A method call on a standard-library value, like `files.iter()` or `buf.extend(…)`, links to your trait's implementation for that type only where the trait is imported. Before, every `String::from("…")` call in dioxus's CLI linked to one such conversion, and tokio's `vec.iter()` calls to an internal trait's `Vec` impl. Re-index Rust projects after upgrading. - In Rust, a type alias, struct, enum or function declared without `pub` now links only from its own module and the modules inside it, and a name a `use` brings in from one of your workspace's crates, like `use tokio_test::task::spawn` or `use crate::error::Error`, links only to an item of that crate or of a crate it depends on. Before, a file's `use crate::Result` could link its `Result<…>` types to another module's private `type Result`, as in dioxus's CLI, and a call to an imported function could link to another crate's function of the same name, such as tokio's test `spawn(…)` calls to one in its examples, or to nothing because a private function of that name was in the way. Re-index Rust projects after upgrading. +- In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/cpp-listed-declarator-receiver.test.ts b/__tests__/cpp-listed-declarator-receiver.test.ts new file mode 100644 index 000000000..4e426d930 --- /dev/null +++ b/__tests__/cpp-listed-declarator-receiver.test.ts @@ -0,0 +1,363 @@ +/** + * A C++ declaration that lists several names — `ByteBuffer symbols_found, + * strings_found;` — gives every one of them its type. + * + * google/breakpad's macho_reader_unittest.cc declares `ByteBuffer + * symbols_found, strings_found;` and calls `symbols_found.Size()` and + * `strings_found.Size()`. Receiver inference reads a declaration with a + * pattern that wants the type right before the name, and a list writes the + * type once, before the first name. So only `symbols_found` was typed: + * `strings_found.Size()` went on to a guess by the receiver's name, which + * found nothing to tell ByteBuffer's `Size` from test_assembler.h's + * `Section::Size`, or, when an earlier function declared a variable of the + * same name, took that variable's type. + * + * A name later in a list has the type the declaration opens with, plus its + * own `*`s and `&`s: `T *a, *b;` declares two pointers, `T *a, b;` one. The + * names are told apart by the commas between them only: a comma inside + * template arguments (`std::map m;`), a call's or a constructor's + * arguments, a constructor's initializer list or an expression lists no + * name, and the receiver's own declaration further up still decides. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import type { Node } from '../src/types'; + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) fs.rmSync(root, { recursive: true, force: true }); +}); + +async function indexed(files: Record): Promise { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cpp-listed-')); + roots.push(root); + 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); + } + return CodeGraph.init(root, { index: true }); +} + +function callable(cg: CodeGraph, qualifiedName: string): Node { + const node = [...cg.getNodesByKind('method'), ...cg.getNodesByKind('function')].find((n) => n.qualifiedName === qualifiedName); + if (!node) throw new Error(`no function or method ${qualifiedName}`); + return node; +} + +/** + * `calls` callees of a function or method, constructors aside, as + * `qualifiedName @confidence`, one per callee. A guess can land on the right + * method by elimination, so the confidence shows how the edge was made: 0.9 + * is the declared type's. + */ +function calls(cg: CodeGraph, caller: string): string[] { + return cg + .getCallees(callable(cg, caller).id) + .filter((r) => r.edge.kind === 'calls') + .filter((r) => { + const [owner, name] = r.node.qualifiedName.split('::').slice(-2); + return owner !== name; + }) + .map((r) => `${r.node.qualifiedName} @${(r.edge.metadata as { confidence?: number } | undefined)?.confidence}`) + .sort(); +} + +const lines = (...l: string[]): string => [...l, ''].join('\n'); + +/** breakpad's ByteBuffer, and test_assembler.h's Section with a `Size` of its own. */ +const BREAKPAD = { + 'src/common/byte_cursor.h': lines( + 'namespace google_breakpad {', + 'class ByteBuffer {', + ' public:', + ' unsigned Size() const { return end - start; }', + ' const unsigned char* start;', + ' const unsigned char* end;', + '};', + '} // namespace google_breakpad', + ), + 'src/common/test_assembler.h': lines( + 'namespace google_breakpad {', + 'namespace test_assembler {', + 'class Section {', + ' public:', + ' unsigned Size() const { return size_; }', + ' private:', + ' unsigned size_;', + '};', + '} // namespace test_assembler', + '} // namespace google_breakpad', + ), +}; + +/** Three classes with a `Get` each, so a receiver given the wrong one of them goes to the wrong `Get`. */ +const STORE = { + 'store/table.h': lines( + 'namespace store {', + 'class Table {', + ' public:', + ' Table() {}', + ' explicit Table(int shard) {}', + ' static Table Open(int shard) { return Table(shard); }', + ' int Get(int key) const { return key; }', + '};', + 'class Log {};', + 'class Writer {', + ' public:', + ' Writer(Log* log, const Table& table, int level) {}', + ' int Get(int key) const { return 0; }', + '};', + 'template ', + 'class Cache {', + ' public:', + ' V* Get(const K& key) { return nullptr; }', + '};', + 'Table Make(int shard, int replicas);', + 'Writer Wrap(Log* log, const Table& table, int level);', + 'void Use(const Writer& writer, const Table& table, int level);', + '} // namespace store', + ), +}; + +describe('a C++ receiver declared later in a list of names', () => { + it('reproduction: `ByteBuffer symbols_found, strings_found;` types strings_found too', async () => { + const cg = await indexed({ + ...BREAKPAD, + 'src/common/mac/macho_reader_unittest.cc': lines( + '#include "common/byte_cursor.h"', + '#include "common/test_assembler.h"', + 'namespace google_breakpad {', + 'unsigned Symtab32() {', + ' ByteBuffer symbols_found, strings_found;', + ' return symbols_found.Size();', + '}', + 'unsigned Symtab64() {', + ' ByteBuffer symbols_found, strings_found;', + ' return strings_found.Size();', + '}', + '} // namespace google_breakpad', + ), + }); + try { + expect(calls(cg, 'google_breakpad::Symtab32')).toEqual(['google_breakpad::ByteBuffer::Size @0.9']); + expect(calls(cg, 'google_breakpad::Symtab64')).toEqual(['google_breakpad::ByteBuffer::Size @0.9']); + expect(cg.getCallers(callable(cg, 'google_breakpad::test_assembler::Section::Size').id) + .filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('initializers, braces and parentheses, template arguments, `auto`, a `for` header and a member list', async () => { + const cg = await indexed({ + ...STORE, + 'store/store.h': lines( + '#include "store/table.h"', + 'namespace store {', + 'class Store {', + ' public:', + ' int Flush();', + ' private:', + ' Table *index_, *data_;', + '};', + '} // namespace store', + ), + 'store/store.cc': lines( + '#include "store/store.h"', + 'namespace store {', + 'int Store::Flush() {', + ' return data_->Get(1);', + '}', + 'int Initialized() {', + ' Table first = Make(1, 2), table;', + ' return table.Get(1);', + '}', + 'int Constructed() {', + ' Table first{1}, second(2);', + ' return second.Get(2);', + '}', + 'int Templated() {', + ' Cache writers, table;', + ' return *table.Get(3);', + '}', + 'int Deduced() {', + ' auto first = Table::Open(1), second = Table::Open(2);', + ' return second.Get(4);', + '}', + 'int Looped(Table* head) {', + ' int sum = 0;', + ' for (Table *t = head, *u = nullptr; t; t = u) {', + ' sum += u->Get(5);', + ' }', + ' return sum;', + '}', + '} // namespace store', + ), + }); + try { + expect(calls(cg, 'store::Store::Flush')).toEqual(['store::Table::Get @0.9']); + expect(calls(cg, 'store::Initialized')).toContain('store::Table::Get @0.9'); + expect(calls(cg, 'store::Constructed')).toEqual(['store::Table::Get @0.9']); + // The comma inside `Cache` separates no names: `table` is a Cache, not a Writer. + expect(calls(cg, 'store::Templated')).toEqual(['store::Cache::Get @0.9']); + // Each name in an `auto` list has its own initializer's type. + expect(calls(cg, 'store::Deduced')).toContain('store::Table::Get @0.9'); + expect(calls(cg, 'store::Looped')).toEqual(['store::Table::Get @0.9']); + expect(cg.getCallers(callable(cg, 'store::Writer::Get').id).filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('the nearest declaration decides, not one of the same name in an earlier function', async () => { + const cg = await indexed({ + ...STORE, + 'store/scan.cc': lines( + '#include "store/table.h"', + 'namespace store {', + 'int Earlier(const Writer& table) {', + ' return table.Get(1);', + '}', + 'int Later() {', + ' Table first, table;', + ' return table.Get(2);', + '}', + '} // namespace store', + ), + }); + try { + expect(calls(cg, 'store::Earlier')).toEqual(['store::Writer::Get @0.9']); + expect(calls(cg, 'store::Later')).toEqual(['store::Table::Get @0.9']); + } finally { + cg.close(); + } + }); + + it('a pointer is each name\'s own: `std::string *out, text;` and `std::string text, *out;`', async () => { + // A call on a std::string is the library's own member, and #2413's check + // that says so needs the call's `.` or `->` to fit the declaration: + // `text` is no pointer in either list, `out` is one in both. + const cg = await indexed({ + 'util/buffers.h': lines( + 'namespace util {', + 'class LeftoverBuffer {', + ' public:', + ' void append(const char* s, unsigned n) {}', + '};', + '} // namespace util', + ), + 'util/encode.cc': lines( + '#include ', + '#include "util/buffers.h"', + 'namespace util {', + 'void Encode(const char* data) {', + ' std::string *out, text;', + ' text.append(data, 4);', + '}', + 'void EncodeTo(const char* data) {', + ' std::string text, *out;', + ' out->append(data, 4);', + '}', + '} // namespace util', + ), + }); + try { + expect(calls(cg, 'util::Encode')).toEqual([]); + expect(calls(cg, 'util::EncodeTo')).toEqual([]); + } finally { + cg.close(); + } + }); + + it('a bare call on a name declared later in a list is a local\'s, as on the first name', async () => { + // C++ lookup finds the local, not a member of the method's class, so a + // bare call on either name is left to the name strategies: `failed()` + // reached Runner::failed as a call on `this`, while `done()` was a local's. + const cg = await indexed({ + 'jobs/runner.h': lines( + '#include ', + 'namespace jobs {', + 'class Runner {', + ' public:', + ' void Run();', + ' void done() {}', + ' void failed() {}', + '};', + 'class Job {', + ' public:', + ' void done() {}', + ' void failed() {}', + '};', + '} // namespace jobs', + ), + 'jobs/runner.cc': lines( + '#include "jobs/runner.h"', + 'namespace jobs {', + 'void Runner::Run() {', + ' std::function done, failed;', + ' done();', + ' failed();', + '}', + '} // namespace jobs', + ), + }); + try { + const run = calls(cg, 'jobs::Runner::Run'); + const howLinked = (method: string): string | undefined => + run.find((c) => c.startsWith(`jobs::Runner::${method} `))?.split('@')[1]; + expect(run).not.toContain('jobs::Runner::failed @0.9'); + expect(howLinked('failed')).toBe(howLinked('done')); + } finally { + cg.close(); + } + }); + + it('a comma in arguments, an initializer list or an expression lists no name', async () => { + const cg = await indexed({ + ...STORE, + 'store/reader.h': lines( + '#include "store/table.h"', + 'namespace store {', + 'class Reader {', + ' public:', + ' Reader(Writer* w, Table* t);', + ' int Run();', + ' private:', + ' Writer* writer_;', + ' Table* table_;', + '};', + '} // namespace store', + ), + 'store/reader.cc': lines( + '#include "store/reader.h"', + 'namespace store {', + 'Reader::Reader(Writer* w, Table* t) : writer_(w), table_(t) {}', + 'int Reader::Run() {', + ' return table_->Get(1);', + '}', + 'int Arguments(Log* log) {', + ' Table table;', + ' Writer writer(log, table, 3);', + ' Writer copy = Wrap(log, table, 3);', + ' Use(writer, table, 3);', + ' return table.Get(2);', + '}', + 'int Parameters(Writer writer, Table table) {', + ' return table.Get(3);', + '}', + '} // namespace store', + ), + }); + try { + expect(calls(cg, 'store::Reader::Run')).toEqual(['store::Table::Get @0.9']); + expect(calls(cg, 'store::Arguments')).toContain('store::Table::Get @0.9'); + expect(calls(cg, 'store::Parameters')).toEqual(['store::Table::Get @0.9']); + expect(cg.getCallers(callable(cg, 'store::Writer::Get').id).filter((r) => r.edge.kind === 'calls')).toEqual([]); + } finally { + cg.close(); + } + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index ad1d3f1c2..7cece60a9 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -9877,6 +9877,243 @@ function cppDeclaratorType(text: string): string { return dropped ? out.replace(/\s+/g, ' ').trim() : text; } +// The receiver after a comma, as a name later in a declaration's list is: +// `ByteBuffer symbols_found, strings_found;`, `Foo *a, *const b;`. The +// declarator regex wants the type right before the name, and a list writes +// it once, before the first name; matchCppListedDeclarator reads the others. +function buildListedDeclaratorRegex(escapedReceiver: string): RegExp { + return new RegExp(String.raw`,(?:\s|[*&]|${CPP_DECLARATOR_CV}|${CPP_DECLARATOR_ANNOTATION})*\b${escapedReceiver}\b`); +} + +// Words a C++ declaration may open with before its type. +const CPP_DECL_SPECIFIERS: ReadonlySet = new Set([ + 'static', 'extern', 'inline', 'constexpr', 'constinit', 'thread_local', 'register', 'mutable', + 'const', 'volatile', 'typename', 'struct', 'class', 'union', 'enum', +]); + +// Words that never are the type a declaration of variables opens with. +const CPP_NOT_A_DECLARED_TYPE: ReadonlySet = new Set([ + ...CPP_NON_TYPE_TOKENS, 'typedef', 'using', 'namespace', 'template', 'friend', 'operator', + 'public', 'private', 'protected', 'virtual', 'explicit', 'static_assert', 'asm', 'try', 'catch', + 'this', 'true', 'false', 'nullptr', +]); + +// Nor a declared variable's name: those, the specifiers and the built-in +// types. `const Message& message, int, int)` ends a parameter list. +const CPP_NOT_A_DECLARED_NAME: ReadonlySet = new Set([ + ...CPP_NOT_A_DECLARED_TYPE, ...CPP_DECL_SPECIFIERS, 'auto', 'void', 'bool', 'char', 'wchar_t', + 'char8_t', 'char16_t', 'char32_t', 'short', 'int', 'long', 'signed', 'unsigned', 'float', 'double', +]); + +const CPP_DECLARATOR_CV_WORD = new RegExp(`^${CPP_DECLARATOR_CV}$`); +const CPP_TRAILING_ANNOTATION_AT = new RegExp(CPP_TRAILING_ANNOTATION, 'y'); + +/** The identifier at `code[i]`, or null. */ +function cppWordAt(code: string, i: number): string | null { + CPP_WORD_AT.lastIndex = i; + return CPP_WORD_AT.exec(code)?.[0] ?? null; +} + +/** Past the whitespace at `code[i]`. */ +function cppSkipSpace(code: string, i: number): number { + while (i < code.length && /\s/.test(code[i]!)) i++; + return i; +} + +/** The word that ends where the spaces before `code[i]` begin (`for` before `for (`). */ +function cppWordBefore(code: string, i: number): string { + let end = i; + while (end > 0 && /\s/.test(code[end - 1]!)) end--; + let start = end; + while (start > 0 && /\w/.test(code[start - 1]!)) start--; + return code.slice(start, end); +} + +/** Does a string or character literal open at `code[i]`? (A `'` after a digit separates digits: `1'000'000`.) */ +function cppOpensLiteral(code: string, i: number): boolean { + return code[i] === '"' || (code[i] === '\'' && !/\d/.test(code[i - 1] ?? '')); +} + +/** + * Past the bracket that closes the `(`, `[` or `{` at `code[i]`, over what + * is nested or quoted inside: the line's length when the line ends first, + * -1 when a bracket closes out of turn. + */ +function cppPastBrackets(code: string, i: number): number { + const closers: string[] = []; + for (; i < code.length; i++) { + const c = code[i]!; + if (c === '(') closers.push(')'); + else if (c === '[') closers.push(']'); + else if (c === '{') closers.push('}'); + else if (c === ')' || c === ']' || c === '}') { + if (closers.pop() !== c) return -1; + if (closers.length === 0) return i + 1; + } else if (cppOpensLiteral(code, i)) i = cppLiteralEnd(code, i); + } + return code.length; +} + +/** + * Where the initializer starting at `code[i]` ends: at the first `,`, `;` + * or closing bracket outside its own brackets, or the line's end; -1 when + * its brackets don't match. A `<` is not taken for a bracket — it may be + * `a < b` — so a comma in template arguments (`f(x)`) ends it, and + * what follows then reads as no declarator. + */ +function cppPastInitializer(code: string, i: number): number { + while (i < code.length) { + const c = code[i]!; + if (c === ',' || c === ';' || c === ')' || c === ']' || c === '}') return i; + if (c === '(' || c === '[' || c === '{') { + i = cppPastBrackets(code, i); + if (i < 0) return -1; + } else if (cppOpensLiteral(code, i)) i = cppLiteralEnd(code, i) + 1; + else i++; + } + return i; +} + +/** Past the template argument list opening at `code[i]` (`<`), or -1 when it doesn't close on the line. */ +function cppPastTemplateArguments(code: string, i: number): number { + let angles = 0; + let parens = 0; + for (; i < code.length; i++) { + const c = code[i]!; + if (c === '(') parens++; + else if (c === ')') { + if (--parens < 0) return -1; + } else if (parens > 0) continue; + else if (c === '<') angles++; + else if (c === '>') { + if (--angles === 0) return i + 1; + } else if (c === ';' || c === '{' || c === '}') return -1; + } + return -1; +} + +/** Past the type a C++ declaration opens with at `code[i]` — `ns::Table>`, `::Foo` — or -1. */ +function cppPastDeclaredType(code: string, i: number): number { + if (code.startsWith('::', i)) i = cppSkipSpace(code, i + 2); + for (let segment = 0; ; segment++) { + let word = cppWordAt(code, i); + // `typename Traits::template Rebind` + if (word === 'template' && segment > 0) { + i = cppSkipSpace(code, i + word.length); + word = cppWordAt(code, i); + } + if (word === null || CPP_NOT_A_DECLARED_TYPE.has(word)) return -1; + i += word.length; + let next = cppSkipSpace(code, i); + if (code[next] === '<') { + i = cppPastTemplateArguments(code, next); + if (i < 0) return -1; + next = cppSkipSpace(code, i); + } + if (!code.startsWith('::', next)) return i; + i = cppSkipSpace(code, next + 2); + } +} + +/** A name a C++ declaration lists after its first, as cppDeclaratorsAfterFirst reads it. */ +interface CppListedName { + name: string; + /** The type the declaration opens with plus this name's own `*`s and `&`s. */ + type: string; + /** Where the declaration's type starts. */ + typeAt: number; + /** Where the name ends, an annotation after it (`GUARDED_BY(mu_)`) included. */ + end: number; +} + +/** + * The names after the first that the C++ declaration starting at + * `code[start]` lists, or null when no declaration starts there. In a `for`, + * `if` or `switch` header a `)` may end it; one that runs on past the line + * ends with it. A declarator's `*`s and `&`s are its own, and so are the + * cv-qualifiers and annotations the declarator regex reads past: `Arena* + * PROTOBUF_NULLABLE a, * PROTOBUF_NULLABLE b`. + */ +function cppDeclaratorsAfterFirst(code: string, start: number, inHeader: boolean): CppListedName[] | null { + let i = cppSkipSpace(code, start); + for (let word = cppWordAt(code, i); word !== null && CPP_DECL_SPECIFIERS.has(word); word = cppWordAt(code, i)) { + i = cppSkipSpace(code, i + word.length); + } + const typeAt = i; + i = cppPastDeclaredType(code, i); + if (i < 0) return null; + const type = code.slice(typeAt, i); + const listed: CppListedName[] = []; + for (let first = true; ; first = false) { + let pointer = ''; + for (i = cppSkipSpace(code, i); ; i = cppSkipSpace(code, i)) { + const c = code[i]; + if (c === '*' || c === '&') { + pointer += c; + i++; + continue; + } + const word = cppWordAt(code, i); + if (word === null) break; + // An annotation word after a `*` or `&` is one only when the name still follows it. + const after = cppSkipSpace(code, i + word.length); + if (!CPP_DECLARATOR_CV_WORD.test(word) && + !(pointer && CPP_DECLARATOR_ANNOTATION_WORD.test(word) && (/[*&]/.test(code[after] ?? '') || cppWordAt(code, after) !== null))) break; + i += word.length; + } + // `Foo a,` with the rest of the list on the next line. + if (i >= code.length && !first && !pointer) return listed; + const name = cppWordAt(code, i); + if (name === null || CPP_NOT_A_DECLARED_NAME.has(name)) return null; + i = cppSkipSpace(code, i + name.length); + // `Foo Bar::baz` defines a member of Bar. + if (code.startsWith('::', i)) return null; + for (CPP_TRAILING_ANNOTATION_AT.lastIndex = i; CPP_TRAILING_ANNOTATION_AT.exec(code); CPP_TRAILING_ANNOTATION_AT.lastIndex = i) { + i = cppSkipSpace(code, CPP_TRAILING_ANNOTATION_AT.lastIndex); + } + if (!first) listed.push({ name, type: type + pointer, typeAt, end: i }); + while (code[i] === '[') { + i = cppPastBrackets(code, i); + if (i < 0) return null; + i = cppSkipSpace(code, i); + } + if (code[i] === '(' || code[i] === '{') i = cppPastBrackets(code, i); + else if (code[i] === '=' && code[i + 1] !== '=') i = cppPastInitializer(code, i + 1); + if (i < 0) return null; + i = cppSkipSpace(code, i); + if (i >= code.length || code[i] === ';' || (inHeader && code[i] === ')')) return listed; + if (code[i] !== ',') return null; + i++; + } +} + +/** + * The declaration of `receiverName` on the code `line` when the name follows + * another in the declaration's list — `ByteBuffer symbols_found, + * strings_found;`, `Foo* a = nullptr, *b;`, `Bar a{1}, b(2);` — shaped like + * matchCppDeclarator's match: the declaration's type from `index`, through + * the name at [0], and at [1] the type as the declarator regex reads one, + * this name's own `*`s and `&`s on the type the declaration opens with, since + * `Foo *a, b;` declares one pointer. A comma in template arguments + * (`std::map m;`), in a call's or a constructor's arguments (`Writer + * w(log, table, 3);`, `: a_(x), b_(y)`) or in an expression (`x = f(a, b);`) + * lists no name. A declaration opens the line, follows a statement or a + * brace, or opens a `for`, `if` or `switch` header. + */ +function matchCppListedDeclarator(line: string, receiverName: string, listedRegex: RegExp): RegExpExecArray | null { + if (!listedRegex.test(line)) return null; + for (let i = -1; i < line.length; i++) { + const c = line[i]; + const header = c === '(' && /^(?:for|if|switch)$/.test(cppWordBefore(line, i)); + if (i >= 0 && c !== ';' && c !== '{' && c !== '}' && !header) continue; + const named = cppDeclaratorsAfterFirst(line, i + 1, header)?.find((d) => d.name === receiverName); + if (named) { + return Object.assign([line.slice(named.typeAt, named.end), named.type], { index: named.typeAt, input: line }) as RegExpExecArray; + } + } + return null; +} + // A range-based for's declaration, `for (const Foo& name :`, up to the single // colon (not `::`) that ends it. What may precede the type — `const`, a // global-scope `::` — is never an initializer, a call or a member access. @@ -10252,6 +10489,10 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const word = new RegExp(`\\b${escaped}\\b`); const declarator = buildDeclaratorRegex(escaped); + const listed = buildListedDeclaratorRegex(escaped); + // `Table *index_, *data_;` declares both members. + const declared = (text: string): RegExpExecArray | null => + matchCppDeclarator(text, declarator) ?? matchCppListedDeclarator(text, name, listed); const end = Math.min(decl.endLine ?? decl.startLine, lines.length); const nested = cppNestedSpans(decl, end, context); const rawLines = cppNonCodeLines(decl.filePath, context); @@ -10265,13 +10506,13 @@ function cppDataMemberIn(decl: Node, name: string, context: ResolutionContext): // above `cfh_iter_pairs_;`), so a default member initializer continued // on the next line (`bool ready_ = flag_ &&` above `it_;`) isn't one. let from = l; - let m = code ? matchCppDeclarator(code, declarator) : null; + let m = code ? declared(code) : null; if (!m && code && l > decl.startLine) { const above = cppCodeOf(cppLiteralsBlanked(lines[l - 2]!, l - 1, rawLines)); if (above && CPP_TYPE_LINE.test(above)) { code = `${above} ${code}`; from = l - 1; - m = matchCppDeclarator(code, declarator); + m = declared(code); } } const rest = m ? code!.slice(m.index + m[0].length) : ''; @@ -12457,6 +12698,7 @@ function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const word = new RegExp(`\\b${escaped}\\b`); const declarator = buildDeclaratorRegex(escaped); + const listed = buildListedDeclaratorRegex(escaped); const nonCode = cppNonCodeLines(caller.filePath, context); for (let i = Math.max(0, caller.startLine - 1); i < Math.min(lines.length, ref.line); i++) { if (!word.test(lines[i]!)) continue; @@ -12469,6 +12711,9 @@ function isCppLocalName(name: string, caller: Node, ref: UnresolvedRef, context: // `Owner::name(` is a definition's own declarator; `return name(` no declaration. if (type !== undefined && !/::\s*$/.test(type) && normalizeCppTypeName(type) !== null) return true; } + // `std::function done, failed;` declares `failed` too. + const listedType = matchCppListedDeclarator(code, name, listed)?.[1]; + if (listedType !== undefined && normalizeCppTypeName(listedType) !== null) return true; } return false; } @@ -12543,6 +12788,7 @@ function inferCppReceiverType( const escapedReceiver = receiverName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const receiverPattern = new RegExp(`\\b${escapedReceiver}\\b`); const declaratorRegex = buildDeclaratorRegex(escapedReceiver); + const listedRegex = buildListedDeclaratorRegex(escapedReceiver); // A declaration in a string literal is text: protobuf's upb generator // writes `upb_MiniTableField field = $field_init$;` in the raw string it // emits, above calls on its own `upb::FieldDefPtr field` parameter. @@ -12564,7 +12810,7 @@ function inferCppReceiverType( const line = cppCodeOf(cppLiteralsBlanked(text, i + 1, rawLines)); if (!line || !receiverPattern.test(line)) continue; - let declaratorMatch = matchCppDeclarator(line, declaratorRegex); + let declaratorMatch = matchCppDeclarator(line, declaratorRegex) ?? matchCppListedDeclarator(line, receiverName, listedRegex); // A parameter named like the function it belongs to is declared after // the function's own name on its head: protobuf's `std::string // u64(uint64_t u64) {` gives `u64` a `uint64_t`, not a `std::string`. @@ -12674,7 +12920,7 @@ function inferCppReceiverType( if (!receiverPattern.test(text)) continue; const line = cppCodeOf(cppLiteralsBlanked(text, i + 1, headerRawLines)); if (!line) continue; - const declaratorMatch = matchCppDeclarator(line, declaratorRegex); + const declaratorMatch = matchCppDeclarator(line, declaratorRegex) ?? matchCppListedDeclarator(line, receiverName, listedRegex); if (!declaratorMatch) continue; const normalized = normalizeCppTypeName(declaratorMatch[1] ?? ''); if (normalized && normalized !== 'auto') { From 5530676f41cf619f03ebc1ecdf0278778111a028 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Tue, 20 Oct 2026 02:08:18 +0000 Subject: [PATCH 8/8] fix(rust): a Self:: call is the function of the type whose impl it is in (#2700) A Rust Self::x(...) call reached the resolver as a method call on a receiver named Self, which names no type. So it fell to a guess by the name alone, and linked only when nothing else in the project shared that name. clap's Self::new(...) calls in impl Error, ValueParser and the like stayed unresolved, and so did tokio's, axum's and serde's. dioxus's Self::current() stopped resolving once its Android tooling's AndroidTools::current was indexed. Self is now the type of the impl (or trait) the call is written in. The call reaches that type's function, or its variant for Self::Text(...), in the type's own file first, then the nearest. As in Rust, the type's inherent function comes first, with cfg twins standing for one another. A trait impl's function is taken only when it is the only one, and several trait impls of one name (From, From) are left unresolved. Inside a trait impl, a call to the function's own name hands on to the type's own function, so it never links to itself there. In the type's own impl such a call is a recursion and stays one. A trait's own method is the fallback only where nothing could shadow it. A type from outside the project, such as axum's impl Listener for tokio::net::TcpListener, has only the impls written beside the call. When the type has no such function, the call stays unresolved instead of going to another type's. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/rust-self-path-calls.test.ts | 209 +++++++++++++++++++++++++ src/resolution/name-matcher.ts | 110 +++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 __tests__/rust-self-path-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6fd53c5b2..9c214f5db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -211,6 +211,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript and TypeScript, a barrel's `export { A, B as C } from './x'` now links each name it passes on to what `./x` exports, following barrels that pass it on in turn. Before, each name was looked up by itself and could link to a same-named symbol elsewhere in the project, such as another folder's `Provider`, and a name passed on from a package, like `export { debounce } from 'lodash'`, could link to a project function of that name. Re-index JavaScript and TypeScript projects after upgrading. - In Swift, a call written after `!` or another prefix operator, or chained after `try` or `await`, now links to what it calls. `if !isReady()` links to `isReady`, `!store.contains(id)` and `try! JSONDecoder().decode(…)` link to the method of the type they're called on rather than to whichever `contains` or `decode` came first, and `!Settings.Defaults.isOn()` lands on the type it names. Re-index Swift projects after upgrading. - In Swift, a call on a local created through `try`, `try!`, `try?` or `await`, or with a generic type's constructor, now links to that type's method: after `let storage = try! DiskStorage.Backend(config: config)`, `storage.store(…)` links to `Backend`'s `store`. Before, such a local had no known type, so the call was left unlinked or matched by name to another type's method. Re-index Swift projects after upgrading. +- In Rust, a call written `Self::new(…)`, `Self::from_std(…)` or `Self::Text(…)` now links to that function or variant of the type whose `impl` it is written in, even when other types have one of the same name. Before, such a call was linked only when nothing else in the project shared the name, so most `Self::new(…)` constructors and many helper calls were missing from callers and impact. Re-index Rust projects after upgrading. - In Rust, a call like `String::from("…")`, `Vec::from([…])` or `NonNull::from(x)` no longer links to your project's own `impl From for String` (or for `Vec`, `NonNull`, …) unless what it passes is a `YourType`. A method call on a standard-library value, like `files.iter()` or `buf.extend(…)`, links to your trait's implementation for that type only where the trait is imported. Before, every `String::from("…")` call in dioxus's CLI linked to one such conversion, and tokio's `vec.iter()` calls to an internal trait's `Vec` impl. Re-index Rust projects after upgrading. - In Rust, a type alias, struct, enum or function declared without `pub` now links only from its own module and the modules inside it, and a name a `use` brings in from one of your workspace's crates, like `use tokio_test::task::spawn` or `use crate::error::Error`, links only to an item of that crate or of a crate it depends on. Before, a file's `use crate::Result` could link its `Result<…>` types to another module's private `type Result`, as in dioxus's CLI, and a call to an imported function could link to another crate's function of the same name, such as tokio's test `spawn(…)` calls to one in its examples, or to nothing because a private function of that name was in the way. Re-index Rust projects after upgrading. - In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. diff --git a/__tests__/rust-self-path-calls.test.ts b/__tests__/rust-self-path-calls.test.ts new file mode 100644 index 000000000..bd96f1cde --- /dev/null +++ b/__tests__/rust-self-path-calls.test.ts @@ -0,0 +1,209 @@ +/** + * A Rust `Self::x(…)` call is `x` of the type `Self` is where it is written: + * `Self::new(…)` in `impl Error` is `Error::new`, `Self::Text(…)` in `impl + * Message` its variant, and in a trait's own body the trait's method. The + * receiver names no type of its own, so a guess by the name alone took such a + * call only when nothing else shared the name: clap's 118 `Self::new(…)`-style + * calls stayed unresolved, as did dioxus's `Self::current()` in `impl + * Workspace` once its Android tooling's `AndroidTools::current` was indexed. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-rust-self-path-')); + const files: Record = { + 'Cargo.toml': '[package]\nname = "cli"\nversion = "0.1.0"\n', + 'src/main.rs': 'mod android;\nmod bound;\nmod config;\nmod error;\nmod frame;\nmod listener;\nmod message;\nmod parse;\nmod parser;\nmod store;\nmod workspace;\n\nfn main() {}\n', + 'src/error.rs': [ + 'pub struct Error {', + ' kind: u8,', + '}', + '', + 'impl Error {', + ' pub fn new(kind: u8) -> Self {', + ' Error { kind }', + ' }', + '', + ' pub fn io() -> Self {', + ' Self::new(1)', + ' }', + '}', + '', + ].join('\n'), + 'src/config.rs': [ + 'use crate::store::Store;', + '', + 'pub struct Config;', + '', + 'impl Config {', + ' pub fn new() -> Self {', + ' Config', + ' }', + '}', + '', + 'impl Store for Config {', + ' fn open() -> Self {', + ' Self::new()', + ' }', + '}', + '', + ].join('\n'), + 'src/store.rs': [ + 'pub trait Store {', + ' fn open() -> Self;', + '', + ' fn open_default() -> Self', + ' where', + ' Self: Sized,', + ' {', + ' Self::open()', + ' }', + '}', + '', + 'pub fn open() {}', + '', + ].join('\n'), + 'src/message.rs': [ + 'pub enum Message {', + ' Text(String),', + ' Binary(Vec),', + '}', + '', + 'impl Message {', + ' pub fn text(s: &str) -> Self {', + ' Self::Text(s.to_string())', + ' }', + '}', + '', + ].join('\n'), + 'src/frame.rs': 'pub enum Frame {\n Text(String),\n}\n', + 'src/workspace.rs': [ + 'pub struct Workspace;', + '', + 'impl Workspace {', + ' pub fn current() -> Workspace {', + ' Workspace', + ' }', + '', + ' pub fn reload() -> Workspace {', + ' Self::current()', + ' }', + '', + ' pub fn depth(n: u32) -> u32 {', + ' if n == 0 { 0 } else { Self::depth(n - 1) + 1 }', + ' }', + '}', + '', + ].join('\n'), + 'src/android.rs': 'pub struct AndroidTools;\n\nimpl AndroidTools {\n pub fn current() -> Option {\n None\n }\n}\n', + // `Self` is `Box`: `Self::new` is `Box::new`, from outside the project, + // not the `new` of a struct that shares the trait's name. + 'src/parse.rs': [ + 'pub trait Parser {', + ' fn parse() -> Self;', + '}', + '', + 'impl Parser for Box {', + ' fn parse() -> Self {', + ' Self::new(T::parse())', + ' }', + '}', + '', + ].join('\n'), + 'src/parser.rs': 'pub struct Parser;\n\nimpl Parser {\n pub fn new() -> Self {\n Parser\n }\n}\n', + // An outside type's own `local_addr` is std's: nothing the index holds. + 'src/bound.rs': [ + 'use std::net::{SocketAddr, TcpListener};', + '', + 'pub trait Bound {', + ' fn local_addr(&self) -> std::io::Result;', + '}', + '', + 'impl Bound for TcpListener {', + ' fn local_addr(&self) -> std::io::Result {', + ' Self::local_addr(self)', + ' }', + '}', + '', + ].join('\n'), + // A trait impl's method handing on to the type's own function of its name. + 'src/listener.rs': [ + 'pub trait Listener {', + ' fn local_addr(&self) -> u16;', + '}', + '', + 'pub struct TcpListener;', + '', + 'impl TcpListener {', + ' pub fn local_addr(&self) -> u16 {', + ' 0', + ' }', + '}', + '', + 'impl Listener for TcpListener {', + ' fn local_addr(&self) -> u16 {', + ' Self::local_addr(self)', + ' }', + '}', + '', + ].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 }); +}, 60_000); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +/** What the `calls` edges out of `fn`, declared in `file` (at `line`, if given), reach, as `file@line`, sorted. */ +const callsFrom = (file: string, fn: string, line?: number): string[] => { + const out: string[] = []; + for (const node of cg.getNodesInFile(file).filter((n) => n.name === fn && (line === undefined || n.startLine === line))) { + for (const edge of cg.getOutgoingEdges(node.id)) { + if (edge.kind !== 'calls') continue; + const target = cg.getNode(edge.target)!; + out.push(`${target.filePath}@${target.startLine}`); + } + } + return [...new Set(out)].sort(); +}; + +describe('a Rust `Self::x(…)` call', () => { + it('is the impl\'s own type\'s function, whatever else shares its name', () => { + expect(callsFrom('src/error.rs', 'io')).toEqual(['src/error.rs@6']); + expect(callsFrom('src/workspace.rs', 'reload')).toEqual(['src/workspace.rs@4']); + // A recursion, in the type's own impl. + expect(callsFrom('src/workspace.rs', 'depth')).toEqual(['src/workspace.rs@12']); + }); + + it('is its variant, for a tuple variant built through `Self`', () => { + expect(callsFrom('src/message.rs', 'text')).toEqual(['src/message.rs@2']); + }); + + it('is the type\'s own in a trait impl, and the trait\'s in the trait\'s own body', () => { + expect(callsFrom('src/config.rs', 'open')).toEqual(['src/config.rs@6']); + expect(callsFrom('src/store.rs', 'open_default')).toEqual(['src/store.rs@2']); + }); + + it('is never another type\'s that shares the trait\'s name', () => { + expect(callsFrom('src/parse.rs', 'parse').filter((t) => t.startsWith('src/parser.rs'))).toEqual([]); + }); + + it('is the type\'s own function first, never the trait method it is written in', () => { + // The trait impl's `local_addr` (line 14) calls the type's own, at line 8. + expect(callsFrom('src/listener.rs', 'local_addr', 14)).toEqual(['src/listener.rs@8']); + expect(callsFrom('src/bound.rs', 'local_addr', 8)).toEqual([]); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7cece60a9..f905764a0 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8076,6 +8076,110 @@ function rustEnclosingImplLine(ref: UnresolvedRef, context: ResolutionContext): return 0; } +interface RustSelfType { + /** Its name (`TcpListener`), or the trait's in a trait's own body. */ + type: string; + /** The first segment of the path it is written with (`tokio` of `tokio::net::TcpListener`), if any. */ + root: string | null; + /** The trait a trait impl implements. */ + trait: string | null; + /** Inside a trait's own body. */ + isTrait: boolean; +} + +/** The type `Self` is where `ref` is written: its `impl`'s implementing type (and trait), or the trait it sits in. */ +function rustSelfType(ref: UnresolvedRef, context: ResolutionContext): RustSelfType | null { + const header = rustEnclosingImplLine(ref, context); + if (header === 0) return null; + const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/) ?? []; + let text = (lines[header - 1] ?? '').replace(/\/\/.*$/, ''); + for (let j = header; !text.includes('{') && j < Math.min(lines.length, header + 8); j++) text += ` ${(lines[j] ?? '').replace(/\/\/.*$/, '')}`; + const trait = /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:unsafe\s+)?trait\s+([A-Za-z_]\w*)/.exec(text)?.[1]; + if (trait) return { type: trait, root: null, trait: null, isTrait: true }; + const impl = parseRustImplHeader(text); + // The type as written: `tokio::net::TcpListener` after a trait impl's `for`, + // or right after an inherent impl's `impl>`. + const written = (impl + ? /\sfor\s+(?:&\s*(?:'\w+\s+)?(?:mut\s+)?)?((?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)/.exec(text) + : /\bimpl\b\s*(?:<(?:[^<>]|<[^<>]*>)*>)?\s*((?:[A-Za-z_]\w*\s*::\s*)*[A-Za-z_]\w*)/.exec(text))?.[1]?.split('::').map((s) => s.trim()) ?? []; + const type = impl ? impl.self : written[written.length - 1]; + if (!type) return null; + return { type, root: written.length > 1 ? written[0]! : null, trait: impl?.trait || null, isTrait: false }; +} + +/** + * Whether `Self`'s type comes from outside the project: written through std's + * or a dependency's path, brought in by an outside `use`, or a std type the + * project doesn't declare. Its own functions are then nothing the index holds. + */ +function isRustOutsideSelf(self: RustSelfType, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (self.root !== null) return self.root === 'std' || self.root === 'core' || self.root === 'alloc' || rustDependencyCrates(context).has(self.root); + const uses = rustUsesOf(ref.filePath, context); + if (uses.external.has(self.type) && !uses.bound.has(self.type)) return true; + return RUST_STD_TYPES.has(self.type) && !isRustProjectNamed(self.type, RUST_DECLARED_TYPE_KINDS, context); +} + +const RUST_SELF_PATH_KINDS = new Set(['method', 'function', 'enum_member']); + +/** + * A Rust `Self::x(…)` call is `x` of the type `Self` is where it is written: + * `Self::new(…)` in `impl Error` is `Error::new`, `Self::Text(…)` in `impl + * Message` its variant, and in a trait impl or a trait's own body the trait's + * method when the type has none. The receiver names no type of its own, so a + * guess by the name alone took such a call only when nothing else shared the + * name: clap's 118 `Self::new(…)`-style calls stayed unresolved, tokio's 56. + * The type's own file first — where its impls are — then the nearest. Null + * when the type has no such function the index holds — the call is then no + * other type's — and undefined when there is no `impl` or `trait` around it. + */ +function matchRustSelfPath(name: string, ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined { + const self = rustSelfType(ref, context); + if (!self) return undefined; + // An outside type (axum's `impl Listener for tokio::net::TcpListener`) has + // only the project's impls on it, written beside the call: another type of + // its name elsewhere is not it. + const outside = !self.isTrait && isRustOutsideSelf(self, ref, context); + // A trait's method is one its own body declares: clap's `impl + // Parser for Box` calls `Self::new`, which is `Box::new`, not the + // `new` of a struct also named `Parser`. + const inTrait = (n: Node, trait: string): boolean => (context.getNodesInFileNamed?.(n.filePath, trait) ?? context.getNodesInFile(n.filePath)) + .some((t) => t.kind === 'trait' && t.name === trait && t.startLine <= n.startLine && (t.endLine ?? 0) >= n.startLine); + const pick = (owner: string, trait = false): Node | null => { + const all = context.getNodesByName(name).filter((n) => n.language === 'rust' && RUST_SELF_PATH_KINDS.has(n.kind) && + (n.qualifiedName === `${owner}::${name}` || n.qualifiedName.endsWith(`::${owner}::${name}`)) && (!trait || inTrait(n, owner)) && + (trait || !outside || n.filePath === ref.filePath) && + // `fn local_addr(&self) { Self::local_addr(self) }` in a trait impl hands + // the call on to the type's own function of that name, never to itself; + // in the type's own impl it is a recursion (gleam's `resolve_type_var_id`). + (self.trait === null || n.id !== ref.fromNodeId)); + // Rust looks up the type's own (inherent) function first, `#[cfg]` twins + // of it standing for one another; a trait impl's only when there is none, + // and several trait impls of one name (`From`, `From`) can't be told apart. + const inherent = all.filter((n) => !isRustTraitImplMethod(n, context)); + const candidates = inherent.length > 0 ? inherent : all.length === 1 ? all : []; + if (candidates.length === 0) return null; + const own = candidates.find((n) => n.filePath === ref.filePath); + if (own) return own; + let best = candidates[0]!; + let score = computePathProximity(ref.filePath, best.filePath); + for (const c of candidates.slice(1)) { + const s = computePathProximity(ref.filePath, c.filePath); + if (s > score) { + score = s; + best = c; + } + } + return best; + }; + // The trait's own method only where nothing could shadow it: an outside + // type may have an inherent one of the name, and `fn local_addr(&self) { + // Self::local_addr(self) }` hands the call on to the type's own. + const delegates = context.getNodeById?.(ref.fromNodeId)?.name === name; + const target = pick(self.type, self.isTrait) ?? (self.trait && !outside && !delegates ? pick(self.trait, true) : null); + if (!target) return null; + return { original: ref, targetNodeId: target.id, confidence: target.filePath === ref.filePath ? 0.9 : 0.8, resolvedBy: 'qualified-name' }; +} + /** Whether `candidate` is declared in the same `impl` / `trait` block as `ref`, above it. */ function isInEnclosingRustImpl(candidate: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { const header = rustEnclosingImplLine(ref, context); @@ -15450,6 +15554,12 @@ export function matchMethodCall( if (literalMatch) return literalMatch; } + // A Rust `Self::x(…)`: `x` of the impl's own type (matchRustSelfPath). + if (ref.language === 'rust' && match === colonMatch && objectOrClass === 'Self') { + const own = matchRustSelfPath(methodName!, ref, context); + if (own !== undefined) return own; + } + // Strategy 1: Direct class name match (existing logic). When the receiver // names a class that exists in several files (`Logger.log()` / `Logger::log()` // with a `Logger` in both `a/` and `b/`), try the class in the call site's