Skip to content

Commit ff0124e

Browse files
feat(cpp): parse CUDA source extensions (#2213)
* feat(cpp): parse CUDA source extensions * test(cpp): characterize CUDA parser limitations --------- Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
1 parent 4c7e066 commit ff0124e

9 files changed

Lines changed: 157 additions & 13 deletions

File tree

gitnexus-shared/src/language-detection.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,17 @@ const EXTENSION_MAP: Record<SupportedLanguages, readonly string[]> = {
3232
[SupportedLanguages.Python]: ['.py'],
3333
[SupportedLanguages.Java]: ['.java'],
3434
[SupportedLanguages.C]: ['.c'],
35-
[SupportedLanguages.CPlusPlus]: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
35+
[SupportedLanguages.CPlusPlus]: [
36+
'.cpp',
37+
'.cc',
38+
'.cxx',
39+
'.h',
40+
'.hpp',
41+
'.hxx',
42+
'.hh',
43+
'.cu',
44+
'.cuh',
45+
],
3646
[SupportedLanguages.CSharp]: ['.cs'],
3747
[SupportedLanguages.Go]: ['.go'],
3848
[SupportedLanguages.Ruby]: ['.rb', '.rake', '.gemspec'],

gitnexus/src/core/group/extractors/include-extractor.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { logger } from '../../logger.js';
3030
/**
3131
* Cross-repo C/C++ `#include` dependency extractor.
3232
*
33-
* **Provider side:** registers every `.h/.hpp/.hxx/.hh` file in the repo
33+
* **Provider side:** registers every `.h/.hpp/.hxx/.hh/.cuh` file in the repo
3434
* as a provider contract with `include::<relative-path>`.
3535
*
3636
* **Consumer side:** parses all C/C++ source/header files for `#include "…"`
@@ -45,13 +45,20 @@ import { logger } from '../../logger.js';
4545

4646
// ---------- constants ----------
4747

48-
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
48+
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh', '.cuh']);
4949

50-
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx).
50+
// Source = headers (provider-eligible) ∪ implementation files (.c/.cpp/.cc/.cxx/.cu).
5151
// Spread keeps the subset relationship explicit so a future contributor adding
5252
// a new header extension to HEADER_EXTENSIONS does not have to remember to
5353
// also add it here.
54-
const SOURCE_EXTENSIONS = new Set<string>([...HEADER_EXTENSIONS, '.c', '.cpp', '.cc', '.cxx']);
54+
const SOURCE_EXTENSIONS = new Set<string>([
55+
...HEADER_EXTENSIONS,
56+
'.c',
57+
'.cpp',
58+
'.cc',
59+
'.cxx',
60+
'.cu',
61+
]);
5562

5663
const INCLUDE_QUERY_SRC = '(preproc_include path: (_) @import.source) @import';
5764

@@ -275,6 +282,8 @@ function getLanguageForFile(filePath: string): unknown | null {
275282
case '.hpp':
276283
case '.hxx':
277284
case '.hh':
285+
case '.cu':
286+
case '.cuh':
278287
return Cpp;
279288
default:
280289
return null;
@@ -298,7 +307,7 @@ function getLanguageForFile(filePath: string): unknown | null {
298307
function isLocalInclude(cleaned: string, suffixIndex: SuffixIndex): boolean {
299308
const candidates = [cleaned];
300309
if (!/\.[a-zA-Z0-9]+$/.test(cleaned)) {
301-
for (const ext of ['.h', '.hpp', '.hxx', '.hh']) candidates.push(cleaned + ext);
310+
for (const ext of HEADER_EXTENSIONS) candidates.push(cleaned + ext);
302311
}
303312
for (const c of candidates) {
304313
if (suffixIndex.get(c) || suffixIndex.getInsensitive(c)) return true;
@@ -428,7 +437,7 @@ export class IncludeExtractor implements ContractExtractor {
428437
try {
429438
const rows = await db(
430439
`MATCH (f:File)
431-
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh)$'
440+
WHERE f.filePath =~ '.*\\\\.(h|hpp|hxx|hh|cuh)$'
432441
RETURN f.filePath AS filePath, f.id AS fileId`,
433442
);
434443
// gitnexus analyze stores absolute paths in the File.filePath column.

gitnexus/src/core/ingestion/import-resolvers/utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export const EXTENSIONS = [
3636
'.cxx',
3737
'.hxx',
3838
'.hh',
39+
'.cu',
40+
'.cuh',
3941
// C#
4042
'.cs',
4143
// Go

gitnexus/src/core/ingestion/languages/c-cpp.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,9 @@ export const cProvider = defineLanguage({
427427

428428
export const cppProvider = defineLanguage({
429429
id: SupportedLanguages.CPlusPlus,
430-
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'],
430+
// CUDA files route through tree-sitter-cpp as a conservative C++-subset parser:
431+
// definitions still extract, but CUDA launch syntax (`<<< >>>`) is not modeled as calls.
432+
extensions: ['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh', '.cu', '.cuh'],
431433
entryPointPatterns: [
432434
/^main$/,
433435
/^init_/,

gitnexus/src/core/ingestion/languages/cpp/header-scan.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@ import { readdirSync, type Dirent } from 'fs';
22
import { join, relative } from 'path';
33

44
/** C++ header extensions to scan for in the workspace. */
5-
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh']);
5+
const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh', '.cuh']);
66

77
/**
88
* Walk `repoPath` recursively and return relative paths of all C++ header files.
99
* Used by `loadResolutionConfig` so the C++ resolver can resolve `#include`
1010
* targets that live in header files.
1111
*
12-
* Scans for: .h, .hpp, .hxx, .hh
12+
* Scans for: .h, .hpp, .hxx, .hh, .cuh
1313
*/
1414
export function scanCppHeaderFiles(repoPath: string): ReadonlySet<string> {
1515
const headers = new Set<string>();

gitnexus/test/integration/group/include-extractor-sync.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,39 @@ describe('IncludeExtractor → syncGroup integration (finding #7)', () => {
192192
fs.rmSync(consumerDir, { recursive: true, force: true });
193193
}
194194
});
195+
196+
it('suppresses extensionless local includes that resolve to .cuh headers', async () => {
197+
const repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gitnexus-include-cuh-'));
198+
try {
199+
fs.mkdirSync(path.join(repoDir, 'include'), { recursive: true });
200+
fs.mkdirSync(path.join(repoDir, 'src'), { recursive: true });
201+
fs.writeFileSync(
202+
path.join(repoDir, 'include/kernel.cuh'),
203+
'#pragma once\n__global__ void kernel();',
204+
);
205+
fs.writeFileSync(
206+
path.join(repoDir, 'src/main.cu'),
207+
'#include "include/kernel"\nvoid host() {}',
208+
);
209+
210+
const extractor = new IncludeExtractor();
211+
const contracts = await extractor.extract(null, repoDir, {
212+
id: 'cuda-repo',
213+
path: 'app/cuda-repo',
214+
repoPath: repoDir,
215+
storagePath: path.join(repoDir, '.gitnexus'),
216+
});
217+
218+
expect(
219+
contracts.some(
220+
(c) => c.role === 'provider' && c.contractId === 'include::include/kernel.cuh',
221+
),
222+
).toBe(true);
223+
expect(
224+
contracts.some((c) => c.role === 'consumer' && c.contractId === 'include::include/kernel'),
225+
).toBe(false);
226+
} finally {
227+
fs.rmSync(repoDir, { recursive: true, force: true });
228+
}
229+
});
195230
});

gitnexus/test/integration/tree-sitter-languages.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ function extractDefinitions(matches: any[]) {
3636
return defs;
3737
}
3838

39+
function extractCapturedCallNames(matches: any[]) {
40+
const names: string[] = [];
41+
for (const match of matches) {
42+
if (!match.captures.some((c: any) => c.name === 'call')) continue;
43+
const nameCapture = match.captures.find((c: any) => c.name === 'call.name');
44+
if (nameCapture) names.push(nameCapture.node.text);
45+
}
46+
return names;
47+
}
48+
3949
describe('Tree-sitter multi-language parsing', () => {
4050
let parser: Parser;
4151

@@ -288,6 +298,48 @@ describe('Tree-sitter multi-language parsing', () => {
288298
expect(names).toContain('helper');
289299
});
290300

301+
it('treats CUDA .cu and .cuh files as C++ for definition extraction', async () => {
302+
expect(getLanguageFromFilename('src/kernels/force.cu')).toBe(SupportedLanguages.CPlusPlus);
303+
expect(getLanguageFromFilename('src/force/nep.cuh')).toBe(SupportedLanguages.CPlusPlus);
304+
305+
await loadLanguage(SupportedLanguages.CPlusPlus, 'src/kernels/force.cu');
306+
const code = `class Force { public: void apply(); };\nvoid launchKernel() {}`;
307+
const provider = getProvider(SupportedLanguages.CPlusPlus);
308+
const { matches } = parseAndQuery(parser, code, provider.treeSitterQueries);
309+
const defs = extractDefinitions(matches);
310+
const names = defs.map((d) => d.name);
311+
312+
expect(defs.some((d) => d.type === 'definition.class' && d.name === 'Force')).toBe(true);
313+
expect(names).toContain('launchKernel');
314+
});
315+
316+
it('characterizes CUDA syntax when routed through the C++ parser', async () => {
317+
await loadLanguage(SupportedLanguages.CPlusPlus, 'src/kernels/force.cu');
318+
const code = `
319+
__global__ void axpy(float *x) { x[0] = 1.0f; }
320+
void host() {
321+
axpy<<<1, 32>>>(nullptr);
322+
cudaDeviceSynchronize();
323+
}
324+
`;
325+
const provider = getProvider(SupportedLanguages.CPlusPlus);
326+
const { tree, matches } = parseAndQuery(parser, code, provider.treeSitterQueries);
327+
const defs = extractDefinitions(matches);
328+
const callNames = extractCapturedCallNames(matches);
329+
const ordinaryCalls = extractCapturedCallNames(
330+
parseAndQuery(
331+
parser,
332+
'void host() { cudaDeviceSynchronize(); }',
333+
provider.treeSitterQueries,
334+
).matches,
335+
);
336+
337+
expect(tree.rootNode.hasError).toBe(true);
338+
expect(defs.some((d) => d.name === 'axpy')).toBe(true);
339+
expect(ordinaryCalls).toContain('cudaDeviceSynchronize');
340+
expect(callNames).not.toContain('axpy');
341+
});
342+
291343
it('captures C++ typedef anonymous structs, enums, and enumerators', async () => {
292344
await loadLanguage(SupportedLanguages.CPlusPlus);
293345
const code = `

gitnexus/test/unit/group/include-extractor.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,16 @@ describe('IncludeExtractor', () => {
6767
expect(providers[0].contractId).toBe('include::utils/helper.hpp');
6868
});
6969

70+
it('registers .cuh CUDA headers as providers', async () => {
71+
writeFile('src/force/nep.cuh', '#pragma once\nclass NEP {};');
72+
73+
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
74+
const providers = contracts.filter((c) => c.role === 'provider');
75+
76+
expect(providers).toHaveLength(1);
77+
expect(providers[0].contractId).toBe('include::src/force/nep.cuh');
78+
});
79+
7080
it('does not register .cpp files as providers', async () => {
7181
writeFile('src/main.cpp', 'int main() { return 0; }');
7282
writeFile('src/utils.h', '#pragma once');
@@ -237,6 +247,22 @@ int main() { return 0; }`,
237247
expect(consumers).toHaveLength(0);
238248
});
239249

250+
it('scans .cu files for includes and resolves local .cuh headers', async () => {
251+
writeFile('include/kernel.cuh', '#pragma once\nvoid launchKernel();');
252+
writeFile(
253+
'src/main.cu',
254+
`#include "include/kernel.cuh"
255+
#include "external/gpu_runtime.cuh"
256+
void launch() { launchKernel(); }`,
257+
);
258+
259+
const contracts = await extractor.extract(null, tmpDir, makeRepo(tmpDir));
260+
const consumers = contracts.filter((c) => c.role === 'consumer');
261+
262+
expect(consumers).toHaveLength(1);
263+
expect(consumers[0].contractId).toBe('include::external/gpu_runtime.cuh');
264+
});
265+
240266
it('resolves locally when include omits extension and a matching .h exists', async () => {
241267
writeFile('foo/bar.h', '#pragma once');
242268
writeFile('src/main.cpp', '#include "foo/bar"\nint main(){return 0;}');

gitnexus/test/unit/ingestion-utils.test.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,12 @@ describe('getLanguageFromFilename', () => {
6868
});
6969

7070
describe('C++', () => {
71-
it.each(['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh'])('detects %s files', (ext) => {
72-
expect(getLanguageFromFilename(`file${ext}`)).toBe(SupportedLanguages.CPlusPlus);
73-
});
71+
it.each(['.cpp', '.cc', '.cxx', '.h', '.hpp', '.hxx', '.hh', '.cu', '.cuh'])(
72+
'detects %s files',
73+
(ext) => {
74+
expect(getLanguageFromFilename(`file${ext}`)).toBe(SupportedLanguages.CPlusPlus);
75+
},
76+
);
7477
});
7578

7679
describe('C#', () => {
@@ -172,6 +175,11 @@ describe('getProviderForFile', () => {
172175
SupportedLanguages.PHP,
173176
);
174177
});
178+
179+
it('routes CUDA C++ source and header files to the C++ provider', () => {
180+
expect(getProviderForFile('src/kernels/integrate.cu')?.id).toBe(SupportedLanguages.CPlusPlus);
181+
expect(getProviderForFile('src/force/nep.cuh')?.id).toBe(SupportedLanguages.CPlusPlus);
182+
});
175183
});
176184

177185
describe('isBuiltInOrNoise', () => {

0 commit comments

Comments
 (0)