Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,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, C++ and Objective-C, a call into a function that another of the three languages defines now links to it even when the name has more than one definition, like a macOS `.m` file's `_glfwPlatformSetTls(…)`, which glfw defines in both `posix_thread.c` and `win32_thread.c`, or a C++ call into a header that reads as C, like Dear ImGui's `ImVector` methods in `imgui.h`. Before, such a call linked to nothing. Re-index C, C++ and Objective-C projects after upgrading. Thanks @ferrine. (#1662)
- 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<std::shared_ptr<EventListener>*>(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<uint32_t>& 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.
Expand Down
132 changes: 132 additions & 0 deletions __tests__/name-match-language-family.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Name matching weighed candidates by their raw language tag, so a `.tsx`
* file's call to the one `.ts` helper of its name was a cross-language guess
* (0.5) and an Objective-C or C++ file's call into C found nothing at all:
*
* app/window.m setTls(slot); // defined in two .c files
* app/main.cpp getTime(); // app/clock.c, or win/clock_win.c
* ui/Row.tsx formatDate(d); // the one in lib/fmt.ts
*
* The one candidate of a name is now judged by language family (TypeScript and
* TSX, Java and Kotlin, C and C++ and Objective-C are one each), and C, C++
* and Objective-C — whose files share one table of names, and whose `.h` files
* are told apart only by sniffing — are ranked as one language. Other
* families keep the language itself for ranking: a `.ts` file's
* `this.app.store.scheduleCapture()` still means the `.ts` Store's method, not
* the `.tsx` App's beside it.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';

const FILES: Record<string, string> = {
// A TSX component calling the one `.ts` helper of the name (a classic
// script: no import or export), and a `.ts` file calling a method of a
// `.ts` class while a `.tsx` class beside it has one of the same name.
'web/ui/Row.tsx': 'export function Row() { return <div>{formatDate(new Date())}</div>; }\n',
'web/lib/fmt.ts': 'function formatDate(d: Date) { return d.toISOString(); }\n',
'web/components/App.tsx': [
'export class App {',
' store = new Store();',
' scheduleCapture() { return 1; }',
' render() { return <div />; }',
'}',
'',
].join('\n'),
'web/components/App.clipboard.ts': [
'export class AppClipboard {',
' constructor(private app: any) {}',
' paste() {',
' this.app.store.scheduleCapture();',
' }',
'}',
'',
].join('\n'),
'web/store/store.ts': [
'export class Store {',
' scheduleCapture() { return 2; }',
'}',
'',
].join('\n'),
// C, C++ and Objective-C: one table of names.
'native/app/init.c': 'void inputError(int code) { (void)code; }\n',
'native/app/posix_thread.c': 'void setTls(int *slot) { (void)slot; }\n',
'native/win/win32_thread.c': 'void setTls(int *slot) { (void)slot; }\n',
'native/app/window.m': [
'#import <Foundation/Foundation.h>',
'',
'void makeCurrent(int *slot) {',
' setTls(slot);',
' inputError(1);',
'}',
'',
].join('\n'),
'native/app/clock.c': 'long getTime(void) { return 1; }\n',
'native/win/clock_win.c': 'long getTime(void) { return 2; }\n',
'native/app/main.cpp': [
'long run() {',
' return getTime();',
'}',
'',
].join('\n'),
};

describe.each(['default', 'wasm'])('name matching reads language families (%s)', (backend) => {
let root = '';
let cg: CodeGraph | undefined;
let kernel: string | undefined;

beforeAll(async () => {
kernel = process.env.CODEGRAPH_KERNEL;
if (backend === 'wasm') process.env.CODEGRAPH_KERNEL = '0';
else delete process.env.CODEGRAPH_KERNEL;
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-lang-family-'));
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?.destroy();
if (root) fs.rmSync(root, { recursive: true, force: true });
if (kernel === undefined) delete process.env.CODEGRAPH_KERNEL;
else process.env.CODEGRAPH_KERNEL = kernel;
});

/** `target file:name@confidence` of each call out of `name` in `file`, sorted. */
const callsFrom = (file: string, name: string): string[] => {
const from = cg!.getNodesInFile(file).filter((n) => n.name === name && (n.kind === 'function' || n.kind === 'method'));
expect(from, `${name} in ${file}`).toHaveLength(1);
return cg!.getOutgoingEdgesFrom([from[0]!.id])
.filter((e) => e.kind === 'calls')
.map((e) => {
const t = cg!.getNode(e.target)!;
return `${t.filePath}:${t.name}@${(e.metadata as { confidence?: number } | undefined)?.confidence}`;
})
.sort();
};

const withoutConfidence = (call: string): string => call.replace(/@.*$/, '');

it('a TSX file calling the one TypeScript function of the name is a same-family match', () => {
expect(callsFrom('web/ui/Row.tsx', 'Row')).toEqual(['web/lib/fmt.ts:formatDate@0.9']);
});

it('TypeScript and TSX are still ranked by their own language', () => {
expect(callsFrom('web/components/App.clipboard.ts', 'paste').map(withoutConfidence)).toEqual(['web/store/store.ts:scheduleCapture']);
});

it('Objective-C calls into C: the one function, and the nearer of two', () => {
const calls = callsFrom('native/app/window.m', 'makeCurrent');
expect(calls.map(withoutConfidence)).toEqual(['native/app/init.c:inputError', 'native/app/posix_thread.c:setTls']);
expect(calls).toContain('native/app/init.c:inputError@0.9');
});

it('C++ calls into C: the nearer of two definitions', () => {
expect(callsFrom('native/app/main.cpp', 'run').map(withoutConfidence)).toEqual(['native/app/clock.c:getTime']);
});
});
47 changes: 34 additions & 13 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,21 @@ export function sameLanguageFamily(a: string, b: string): boolean {
const fa = LANGUAGE_FAMILY[a];
return fa !== undefined && fa === LANGUAGE_FAMILY[b];
}

/**
* The languages whose files share one table of names: a C function is called
* by its bare name from C++ and Objective-C alike, and a `.h` file is C, C++
* or Objective-C only as far as sniffing its text can tell (Dear ImGui's
* C++ `imgui.h` reads as C), so name matching ranks them as one language.
* Other families keep the language itself: their files reach each other's
* names through imports, and a name-only pick across TypeScript and TSX, or
* Java and Kotlin, went to a same-named declaration in the next file more
* often than to the one meant (#1662).
*/
const C_LINKED_LANGUAGES: ReadonlySet<string> = new Set(['c', 'cpp', 'objc']);
function sameRankingLanguage(a: string, b: string): boolean {
return a === b || (C_LINKED_LANGUAGES.has(a) && C_LINKED_LANGUAGES.has(b));
}
/** Config/markup transitions stay open; every other code language has a family. */
const CODE_FAMILY: Record<string, string> = {
...LANGUAGE_FAMILY,
Expand Down Expand Up @@ -13283,10 +13298,11 @@ export function matchByExactName(
return null;
}

// If only one match, use it — but penalize cross-language matches
// If only one match, use it — but penalize a match in another language
// family (a `.tsx` component calling a `.ts` helper is not one, #1662)
if (candidates.length === 1) {
if (!isCrossFileReachable(candidates[0]!, ref, context)) return null;
const isCrossLanguage = candidates[0]!.language !== ref.language;
const isCrossLanguage = !sameLanguageFamily(candidates[0]!.language, ref.language);
return {
original: ref,
targetNodeId: candidates[0]!.id,
Expand Down Expand Up @@ -13334,10 +13350,11 @@ export function matchByExactName(
// above a proximity guess, but below a unique name or the file's own
// definition (0.9). The candidates are those findBestMatch weighed: a
// namesake in another language never wins while one in the reference's
// own language exists. The caller's own copy being the caller itself is
// no evidence, as for a same-file match (isDecisiveSameFileMatch).
const weighed = candidates.some((c) => c.language === ref.language)
? candidates.filter((c) => c.language === ref.language)
// own language (sameRankingLanguage) exists. The caller's own copy being
// the caller itself is no evidence, as for a same-file match
// (isDecisiveSameFileMatch).
const weighed = candidates.some((c) => sameRankingLanguage(c.language, ref.language))
? candidates.filter((c) => sameRankingLanguage(c.language, ref.language))
: candidates;
const confidence = isDecisiveSameFileMatch(bestMatch, ref, candidates, context)
? 0.9
Expand Down Expand Up @@ -29385,7 +29402,8 @@ function findBestMatch(
// Prioritization rules:
// 1. Same file > different file
// 2. Directory proximity (same module/package > different module)
// 3. Same language > different language
// 3. Same language > different language (C, C++ and Objective-C count as
// one: sameRankingLanguage), and the very language as a tie-break
// 4. Functions/methods > classes/types (for call references)
// 5. Exported > non-exported

Expand All @@ -29406,10 +29424,11 @@ function findBestMatch(
// scoring. Cuts the candidate set to same-language size on mixed front-end +
// back-end repos (#915). When ALL candidates are cross-language (a legitimate
// cross-language `calls` bridge), none are skipped and behavior is unchanged.
const hasSameLanguage = candidates.some((c) => c.language === ref.language);
const hasSameLanguage = candidates.some((c) => sameRankingLanguage(c.language, ref.language));

for (const candidate of candidates) {
if (hasSameLanguage && candidate.language !== ref.language) continue;
const sameLanguage = sameRankingLanguage(candidate.language, ref.language);
if (hasSameLanguage && !sameLanguage) continue;

let score = 0;

Expand All @@ -29428,9 +29447,11 @@ function findBestMatch(
score += 80;
}

// Language matching: strongly prefer same language, penalize cross-language
if (candidate.language === ref.language) {
score += 50;
// Language matching: strongly prefer same language, penalize cross-language.
// Between C, C++ and Objective-C the very language adds one point, which
// only breaks a tie: a directory nearer counts 15.
if (sameLanguage) {
score += candidate.language === ref.language ? 51 : 50;
} else {
score -= 80;
}
Expand Down Expand Up @@ -29662,7 +29683,7 @@ export function matchFuzzy(
!(finalCandidates[0]!.kind !== 'function' && isBarePhpCall(ref, context)) &&
isLexicallyReachable(finalCandidates[0]!, ref, context)
) {
const isCrossLanguage = finalCandidates[0]!.language !== ref.language;
const isCrossLanguage = !sameLanguageFamily(finalCandidates[0]!.language, ref.language);
return {
original: ref,
targetNodeId: finalCandidates[0]!.id,
Expand Down