diff --git a/gitnexus-shared/src/scope-resolution/types.ts b/gitnexus-shared/src/scope-resolution/types.ts index 50a968146..5bad9673b 100644 --- a/gitnexus-shared/src/scope-resolution/types.ts +++ b/gitnexus-shared/src/scope-resolution/types.ts @@ -379,6 +379,12 @@ type ParsedImportSyntax = * deferred — `use` does not execute * (`LanguageProvider.importsExecuteWhereWritten`). */ readonly runsOnlyWhenCalled?: boolean; + /** + * C/C++ `#include <…>` (true) versus `#include "…"`. + * Angle includes resolve only on header search paths. Quoted includes + * may still use the including file's directory and the basename index. + */ + readonly isSystem?: boolean; } /** * Runtime-computed target — the import path is not a static literal at diff --git a/gitnexus/bench/import-target/baselines.json b/gitnexus/bench/import-target/baselines.json index 317f56898..006014d18 100644 --- a/gitnexus/bench/import-target/baselines.json +++ b/gitnexus/bench/import-target/baselines.json @@ -981,6 +981,11 @@ "depth_ratio": 1.938, "scaling_ratio": 1.033, "small_ms": 1.649 + }, + "context": { + "target": "stdio.h", + "with_context": "", + "without_context": "src/stdio.h" } }, "cpp": { @@ -1032,6 +1037,11 @@ "depth_ratio": 2.064, "scaling_ratio": 1.167, "small_ms": 1.626 + }, + "context": { + "target": "cstdio.h", + "with_context": "", + "without_context": "src/cstdio.h" } }, "objc": { diff --git a/gitnexus/bench/import-target/measure.mjs b/gitnexus/bench/import-target/measure.mjs index 6290a8893..2690e9e48 100644 --- a/gitnexus/bench/import-target/measure.mjs +++ b/gitnexus/bench/import-target/measure.mjs @@ -105,12 +105,14 @@ * path components, so the `deep` arm's uniform prefix reaches the * config (see `tsBaseUrlFor`) and its cost is the same keyed lookup the * other arms pay. - * - c, cpp: `resolveCppImportTarget` delegates to `resolveCImportTarget`, so - * the two share a resolver and differ in extension set and in which adapter - * builds the augmented set. Cost is a basename bucket walk with a - * depth-then-lexicographic tie-break, so the collide arm (a `mod{n}` header - * in every service's `include/`) is where it grows: 2.54 / 2.64 against - * 1.06 on file count. + * - c, cpp: quoted includes walk a basename bucket (depth, then lexicographic). + * The collide arm — a `mod{n}` header in every service's `include/` — is + * where that walk grows: 2.54 / 2.64 against 1.06 on file count. Each + * language keeps its own bucket memo. Angle includes join the target onto + * header search paths and never take that walk. The timing corpus passes + * a raw header set and no `isSystem` flag, so it stays on the quoted path. + * `--check` also resolves a quoted hit, an angle hit on a declared include + * root, and an angle miss against a same-named `src/` decoy. * - zig: `resolveZigImportInternal` is rust's shape — an `@import("…zig")` * path is walked component by component from the importer's directory and * probed with two `allFiles.has(...)` calls (as written, then `+ '.zig'`), @@ -618,18 +620,19 @@ const HEAP_BUDGETED = [ // were about. /** - * The arms handed the fifth `context` argument — `{ parsedFiles, parsedImport }` - * — because their registered hook DECLARES it. Four of seventeen arms, and the - * inventory arm at the foot of this file reconciles that claim against - * `SCOPE_RESOLVERS` in both directions rather than trusting this line. + * Arms whose registered hook declares the fifth `context` argument + * (`{ parsedFiles, parsedImport }`). The inventory at the foot of this file + * reconciles this list against `SCOPE_RESOLVERS` in both directions, so + * membership is that check rather than a count written here. * - * These are also the only arms for which `newPass` builds a `ParsedFile[]` at - * all. Building one for the other thirteen would cost their timed loop an - * O(files) allocation per pass that no resolver of theirs can even observe — - * their hooks declare three or four parameters — so their numbers stay exactly - * where they were. + * `newPass` builds a `ParsedFile[]` only for the members that are not header + * languages. C and C++ are listed because they read `parsedImport.isSystem`, + * but `newPass` returns on `HEADER_EXTENSION` before this list, so a timed + * pass does not allocate that array for them. Arms whose hooks declare fewer + * parameters cannot observe a context and are not listed, so their timed + * numbers stay on the three-argument shape. */ -const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift']; +const CONTEXT_LANGS = ['php', 'java', 'kotlin', 'python', 'swift', 'c', 'cpp']; /** * Needs `node --expose-gc` to force collection for a clean delta; without it @@ -1732,8 +1735,11 @@ function buildRepo(lang, fileCount, pad = 0, shape = 'unique') { * `csharp_csproj` is the precedent and stays where it is: a per-language * CONTEXT over a corpus aliased to another language's, rather than a new axis. * - * `parsedFiles` is the third pass-stable object, present for `CONTEXT_LANGS` - * and undefined for everyone else. It is built BEFORE the path set and the path + * `parsedFiles` is the third pass-stable object. `newPass` builds it for the + * `CONTEXT_LANGS` members that are not header languages, and leaves it + * undefined otherwise — C and C++ are in that list but return on + * `HEADER_EXTENSION` first, so a timed pass does not allocate the array. + * Where it is built, it is built BEFORE the path set and the path * set is derived FROM it, which is not a stylistic choice: `run.ts` does * `new Set(parsedFiles.map((f) => f.filePath))`, so two independently built * lists would be a shape the pipeline cannot produce. Fresh per pass for @@ -1930,10 +1936,22 @@ function resolveOne(lang, from, target, pass) { return typescriptScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); } if (lang === 'c') { - return cScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + return cScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + pass.includeContext, + ); } if (lang === 'cpp') { - return cppScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); + return cppScopeResolver.resolveImportTarget( + target, + from, + allFilePaths, + pass.config, + pass.includeContext, + ); } if (lang === 'objc') { return objectiveCScopeResolver.resolveImportTarget(target, from, allFilePaths, pass.config); @@ -2364,6 +2382,30 @@ const CONTEXT_PROBE = { probeFile('Sources/Client/Main.swift', [['Class', 'Client.Main']]), ], }, + /** + * `#include ` with a repo file `src/stdio.h`. With the fifth + * argument the include is an angle include and the decoy is not on a + * search path, so the answer is null. Without it the call is the quoted + * basename walk and the decoy wins. Same shape for C++ `cstdio.h`. + */ + c: { + from: 'src/main.c', + target: 'stdio.h', + parsedFiles: [ + probeFile('src/main.c', []), + probeFile('src/stdio.h', []), + probeFile('include/util.h', []), + ], + }, + cpp: { + from: 'src/main.cpp', + target: 'cstdio.h', + parsedFiles: [ + probeFile('src/main.cpp', []), + probeFile('src/cstdio.h', []), + probeFile('include/util.hpp', []), + ], + }, }; /** Resolve the probe twice through `resolveOne` — once with the pass's parsed @@ -2375,9 +2417,14 @@ function measureContext(lang) { const config = lang === 'php' ? phpComposerConfigFor(0) : undefined; const answer = (files) => { restoreBenchmarkSideChannels(lang, files ?? []); - return renderResolved( - resolveOne(lang, from, target, { allFilePaths, config, parsedFiles: files }), - ); + const pass = { allFilePaths, config, parsedFiles: files }; + if ((lang === 'c' || lang === 'cpp') && files !== undefined) { + pass.includeContext = { + parsedFiles: files, + parsedImport: { kind: 'wildcard', targetRaw: target, isSystem: true }, + }; + } + return renderResolved(resolveOne(lang, from, target, pass)); }; return { target, @@ -2386,6 +2433,85 @@ function measureContext(lang) { }; } +/** + * Quote vs angle for C and C++, on top of the scaling arms. The timing corpus + * stays quoted (no `isSystem`) so its fingerprints do not move. This is the + * arm that fails if angle includes go back to a repo-wide name hunt, or if a + * raw header set stops resolving a quoted include. + */ +function checkCIncludeForms(failures) { + const arms = [ + { + lang: 'c', + resolver: cScopeResolver, + from: 'src/main.c', + files: ['src/main.c', 'src/stdio.h', 'include/util.h'], + quoted: 'util.h', + quotedHit: 'include/util.h', + angleHit: 'util.h', + angleHitFile: 'include/util.h', + angleMiss: 'stdio.h', + }, + { + lang: 'cpp', + resolver: cppScopeResolver, + from: 'src/main.cpp', + files: ['src/main.cpp', 'src/cstdio.h', 'include/util.hpp'], + quoted: 'util.hpp', + quotedHit: 'include/util.hpp', + angleHit: 'util.hpp', + angleHitFile: 'include/util.hpp', + angleMiss: 'cstdio.h', + }, + ]; + for (const arm of arms) { + const allFilePaths = new Set(arm.files); + const config = { + headers: new Set(arm.files.filter((file) => file !== arm.from)), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + const resolve = (target, isSystem) => + arm.resolver.resolveImportTarget(target, arm.from, allFilePaths, config, { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw: target, isSystem }, + }); + const quotedResult = resolve(arm.quoted, false); + const angledResult = resolve(arm.angleHit, true); + const missedResult = resolve(arm.angleMiss, true); + if (quotedResult !== arm.quotedHit) { + failures.push( + `${arm.lang}: quoted "${arm.quoted}" resolved to ${JSON.stringify(quotedResult)}, ` + + `expected ${arm.quotedHit}`, + ); + } + if (angledResult !== arm.angleHitFile) { + failures.push( + `${arm.lang}: angle <${arm.angleHit}> resolved to ${JSON.stringify(angledResult)}, ` + + `expected ${arm.angleHitFile} on the declared include root`, + ); + } + if (missedResult !== null) { + failures.push( + `${arm.lang}: angle <${arm.angleMiss}> resolved to ${JSON.stringify(missedResult)}; ` + + `a same-named file outside the include root must miss`, + ); + } + const raw = arm.resolver.resolveImportTarget( + arm.quoted, + arm.from, + new Set([arm.from]), + new Set(arm.files.filter((file) => file !== arm.from)), + ); + if (raw !== arm.quotedHit) { + failures.push( + `${arm.lang}: a raw header set resolved quoted "${arm.quoted}" to ${JSON.stringify(raw)}, ` + + `expected ${arm.quotedHit}`, + ); + } + } +} + function fingerprint(outcomes) { return crypto .createHash('sha256') @@ -2556,6 +2682,7 @@ if (!CHECK) { const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); const failures = []; +checkCIncludeForms(failures); /** * PRESENCE, for one budget, in the one place that spells the reason. @@ -3102,10 +3229,12 @@ expectNoOrphanKeys( // The SAME reconciliation for `CONTEXT_LANGS`, against the registry rather than // against a claim in a comment. `run.ts` passes the fifth argument to every // provider; which ones can OBSERVE it is decided by how many parameters each -// hook declares, and that is a number the registry can be asked for. Today -// exactly five answer 5 (php, java, kotlin, python, swift) and the other twelve answer 3 or 4 — -// which is why thirteen arms can ignore this whole question and their numbers -// did not move when it was fixed. +// hook declares, and that is a number the registry can be asked for. php, +// java, kotlin, python, and swift read the parsed workspace; c and cpp read +// `parsedImport.isSystem` and nothing else on that object. Their timed passes +// still return before any parsed-file build (see `newPass`), so the fifth +// argument shows up here as the include-form probe, not as a cost on the +// scaling arms. // // `Function.length` stops at the first defaulted or rest parameter, so a hook // written as `(a, b, c, d, context = {})` would read 4 and slip past this arm. diff --git a/gitnexus/src/core/ingestion/languages/c/header-scan.ts b/gitnexus/src/core/ingestion/languages/c/header-scan.ts index 034e27f4d..a1f67c66e 100644 --- a/gitnexus/src/core/ingestion/languages/c/header-scan.ts +++ b/gitnexus/src/core/ingestion/languages/c/header-scan.ts @@ -1,58 +1,9 @@ -import { readdirSync, type Dirent } from 'fs'; -import { join, relative } from 'path'; - -/** C header extensions to scan for in the workspace. */ -const HEADER_EXTENSIONS = new Set(['.h']); +import { C_HEADER_EXTENSIONS, scanCFamilyHeaders } from './resolution-config.js'; /** - * Walk `repoPath` recursively and return relative paths of all `.h` files. - * Used by `loadResolutionConfig` so the C resolver can resolve `#include` - * targets that live in `.h` files (classified as C++ by language detection - * but importable from `.c` files). + * Walk `repoPath` and return relative paths of all `.h` files. + * Search paths stay on the workspace config; this is only the header set. */ export function scanHeaderFiles(repoPath: string): ReadonlySet { - const headers = new Set(); - walk(repoPath, repoPath, headers); - return headers; -} - -function walk(dir: string, root: string, out: Set): void { - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); - } catch { - return; // permission denied, etc. - } - for (const entry of entries) { - const name = entry.name; - const full = join(dir, name); - if (entry.isDirectory()) { - // Skip common non-source directories and build output dirs. - // Build dirs (dist, build, out, target, _build, .next, cmake-build-*) - // may contain generated headers that shadow source headers. - if ( - name === 'node_modules' || - name === '.git' || - name === 'vendor' || - name === 'dist' || - name === 'build' || - name === 'out' || - name === 'target' || - name === '_build' || - name === '.next' || - name.startsWith('cmake-build') - ) { - continue; - } - walk(full, root, out); - } else if (entry.isFile()) { - const ext = name.slice(name.lastIndexOf('.')); - if (HEADER_EXTENSIONS.has(ext)) { - // Normalize to forward slashes for cross-platform consistency. - // path.relative() returns backslash-separated paths on Windows, - // but the scope-resolution pipeline uses forward slashes uniformly. - out.add(relative(root, full).replace(/\\/g, '/')); - } - } - } + return scanCFamilyHeaders(repoPath, C_HEADER_EXTENSIONS).headers; } diff --git a/gitnexus/src/core/ingestion/languages/c/import-target.ts b/gitnexus/src/core/ingestion/languages/c/import-target.ts index 5590bb2e6..07f79df3c 100644 --- a/gitnexus/src/core/ingestion/languages/c/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/c/import-target.ts @@ -1,5 +1,11 @@ import { dirname, join } from 'path'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; +import { + collapseRepoPath, + nearestScope, + normalizeRepoPath, + type CTranslationUnitPaths, +} from './resolution-config.js'; /** * A workspace file path pre-decomposed for the suffix-match fallback: @@ -7,64 +13,120 @@ import { perFileSet } from '../../import-resolvers/per-file-set.js'; * contract); `normalized` and `depth` are precomputed so the hot path does no * per-element regex/`split`. */ -interface CSuffixCandidate { +export interface CSuffixCandidate { original: string; normalized: string; depth: number; } +export type CIncludeSuffixIndex = ( + allFilePaths: ReadonlySet, +) => Map; + /** - * Per-pass memo: workspace paths bucketed by basename (last path segment), - * keyed on the `allFilePaths` set identity. + * Basename buckets for quoted `#include "…"` suffix fallback. * - * `resolveCImportTarget` is called once per (quoted) C/C++ `#include` with the - * same `allFilePaths` set per pass (the augmented set is itself memoized in - * the C resolver). The old suffix-match fallback scanned ALL workspace paths - * per include — with a per-element `.replace`/`.split` and no early exit - * (the fewest-path-components tie-break forces a full scan) — i.e. - * O(R_suffix × (F+H)). A path can satisfy `endsWith('/'+target)` (or equal - * the target) ONLY IF its basename equals the target's last segment, so we - * pre-bucket by basename once (O(F+H), `normalized`/`depth` precomputed) and - * the fallback inspects a single small bucket → O(F+H) build + ~O(1)/include. - * `WeakMap`-keyed so it is reclaimed with the pass (no cross-pass staleness). - * Shared by C and C++ (`resolveCppImportTarget` delegates here). + * The builder is shared. Each language keeps its own memo: C++ calls + * `resolveCFamilyImport` with the index it created here, so a C pass and a + * C++ pass never read each other's map even when handed the same set. */ -const suffixIndex = perFileSet((allFilePaths: ReadonlySet) => { - const index = new Map(); - for (const original of allFilePaths) { - const normalized = original.replace(/\\/g, '/'); - const basename = normalized.slice(normalized.lastIndexOf('/') + 1); - let bucket = index.get(basename); - if (bucket === undefined) { - bucket = []; - index.set(basename, bucket); +export function createCIncludeSuffixIndex(): CIncludeSuffixIndex { + return perFileSet((allFilePaths: ReadonlySet) => { + const index = new Map(); + for (const original of allFilePaths) { + const normalized = original.replace(/\\/g, '/'); + const basename = normalized.slice(normalized.lastIndexOf('/') + 1); + let bucket = index.get(basename); + if (bucket === undefined) { + bucket = []; + index.set(basename, bucket); + } + bucket.push({ original, normalized, depth: normalized.split('/').length }); } - bucket.push({ original, normalized, depth: normalized.split('/').length }); - } - return index; -}); + return index; + }); +} + +const cSuffixIndex = createCIncludeSuffixIndex(); + +/** Optional search roots. Absent means "quoted include, no declared paths". */ +export interface CIncludeLookup { + /** `#include <…>`. Never suffix-matches the workspace. */ + readonly isSystem?: boolean; + /** Fallback `-I` / `-isystem` / `/I` and implicit roots. */ + readonly headerSearchPaths?: readonly string[]; + /** Fallback `-iquote`. Quoted includes only. */ + readonly userHeaderSearchPaths?: readonly string[]; + /** + * Per translation unit. A hit replaces the fallback lists for that file. + * The map is the one built at config load — lookup does not copy it. + */ + readonly translationUnits?: ReadonlyMap; + /** Nearest-ancestor lists for a file with no database entry. */ + readonly directoryScopes?: ReadonlyMap; + /** Interface include roots from `target_link_libraries`, keyed by source file. */ + readonly cmakeLinkHeaders?: ReadonlyMap; +} /** * Resolve a C #include path to a file in the workspace. * - * Strategy: - * 1. Check for a same-directory sibling relative to the including file - * (matches C compiler `#include "…"` relative-lookup semantics). - * 2. Check for an exact match (path as-is in the workspace). - * 3. Fall back to suffix matching against all workspace file paths. - * Tie-breaking: prefer the match with the fewest path components - * (closest to root). On equal depth, break ties lexicographically - * by normalized path to ensure deterministic resolution regardless - * of filesystem iteration order. + * Quoted `#include "…"` (the default when `lookup` is omitted, which is + * what the import-target bench and the older unit tests call): + * 1. Same directory as the including file + * 2. User search paths, then header search paths + * 3. Exact path, then the basename bucket + * + * Angle `#include <…>` (`lookup.isSystem`): join the target onto each + * header search path and return that file, or null. A same-named header + * under `src/` is not a candidate when `src` is not a search path. */ +export function cIncludeLookupFromConfig( + config: + | { + readonly headerSearchPaths?: readonly string[]; + readonly userHeaderSearchPaths?: readonly string[]; + readonly translationUnits?: ReadonlyMap; + readonly directoryScopes?: ReadonlyMap; + readonly cmakeLinkHeaders?: ReadonlyMap; + } + | undefined, + isSystem: boolean, +): CIncludeLookup { + return { + isSystem, + headerSearchPaths: config?.headerSearchPaths, + userHeaderSearchPaths: config?.userHeaderSearchPaths, + translationUnits: config?.translationUnits, + directoryScopes: config?.directoryScopes, + cmakeLinkHeaders: config?.cmakeLinkHeaders, + }; +} + export function resolveCImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, + lookup?: CIncludeLookup, +): string | null { + return resolveCFamilyImport(targetRaw, fromFile, allFilePaths, lookup, cSuffixIndex); +} + +export function resolveCFamilyImport( + targetRaw: string, + fromFile: string, + allFilePaths: ReadonlySet, + lookup: CIncludeLookup | undefined, + suffixIndex: CIncludeSuffixIndex, ): string | null { if (!targetRaw) return null; const normalizedTarget = targetRaw.replace(/\\/g, '/'); + const lists = listsFor(lookup, fromFile); + + if (lookup?.isSystem === true) { + return matchSearchPaths(normalizedTarget, lists.header, allFilePaths); + } // Same-directory sibling first: mirrors the C compiler's #include "…" // relative-lookup semantics where the directory of the including @@ -82,6 +144,15 @@ export function resolveCImportTarget( } } + const onUserPath = + lists.user.length === 0 ? null : matchSearchPaths(normalizedTarget, lists.user, allFilePaths); + if (onUserPath !== null) return onUserPath; + const onHeaderPath = + lists.header.length === 0 + ? null + : matchSearchPaths(normalizedTarget, lists.header, allFilePaths); + if (onHeaderPath !== null) return onHeaderPath; + // Exact match (path as-is in the workspace) if (allFilePaths.has(normalizedTarget)) return normalizedTarget; @@ -116,3 +187,114 @@ export function resolveCImportTarget( return bestMatch; } + +function listsFor( + lookup: CIncludeLookup | undefined, + fromFile: string, +): { readonly header: readonly string[]; readonly user: readonly string[] } { + const units = lookup?.translationUnits; + if (units !== undefined && units.size > 0) { + const exact = units.get(normalizeRepoPath(fromFile)); + if (exact !== undefined) { + return withCmakeLinks(lookup, fromFile, { + header: exact.headerSearchPaths, + user: exact.userHeaderSearchPaths, + }); + } + const near = pathsFromTranslationUnits(units, fromFile); + if (near !== undefined) return withCmakeLinks(lookup, fromFile, near); + } + const scopes = lookup?.directoryScopes; + const paths = + scopes !== undefined && scopes.size > 0 ? nearestScope(scopes, fromFile) : undefined; + if (paths !== undefined) { + return withCmakeLinks(lookup, fromFile, { + header: paths.headerSearchPaths, + user: paths.userHeaderSearchPaths, + }); + } + if (units !== undefined && units.size > 0) { + return withCmakeLinks(lookup, fromFile, { header: [], user: [] }); + } + return withCmakeLinks(lookup, fromFile, { + header: lookup?.headerSearchPaths ?? [], + user: lookup?.userHeaderSearchPaths ?? [], + }); +} + +function withCmakeLinks( + lookup: CIncludeLookup | undefined, + fromFile: string, + lists: { readonly header: readonly string[]; readonly user: readonly string[] }, +): { readonly header: readonly string[]; readonly user: readonly string[] } { + const extra = cmakeLinksFor(lookup?.cmakeLinkHeaders, fromFile); + if (extra.length === 0) return lists; + return { header: [...new Set([...lists.header, ...extra])], user: lists.user }; +} + +function cmakeLinksFor( + links: ReadonlyMap | undefined, + fromFile: string, +): readonly string[] { + if (links === undefined || links.size === 0) return []; + const file = normalizeRepoPath(fromFile); + const exact = links.get(file); + if (exact !== undefined) return exact; + const dir = directoryOf(file); + const header: string[] = []; + for (const [source, paths] of links) { + if (directoryOf(source) !== dir) continue; + header.push(...paths); + } + return [...new Set(header)]; +} + +/** + * The database lists source files, not the headers they include. A header + * uses the `-I` / `-iquote` lists of a translation unit in the same directory. + * A file in another directory does not inherit that list. + */ +function pathsFromTranslationUnits( + units: ReadonlyMap, + fromFile: string, +): { readonly header: readonly string[]; readonly user: readonly string[] } | undefined { + const file = normalizeRepoPath(fromFile); + const dir = directoryOf(file); + const header: string[] = []; + const user: string[] = []; + let found = false; + for (const [unitFile, paths] of units) { + if (directoryOf(unitFile) !== dir) continue; + found = true; + header.push(...paths.headerSearchPaths); + user.push(...paths.userHeaderSearchPaths); + } + if (!found) return undefined; + return { header: [...new Set(header)], user: [...new Set(user)] }; +} + +function directoryOf(file: string): string { + const normalized = normalizeRepoPath(file); + const slash = normalized.lastIndexOf('/'); + return slash === -1 ? '' : normalized.slice(0, slash); +} + +function matchSearchPaths( + normalizedTarget: string, + roots: readonly string[], + allFilePaths: ReadonlySet, +): string | null { + if (roots.length === 0 || normalizedTarget.length === 0) return null; + for (const root of roots) { + const candidate = joinSearch(root, normalizedTarget); + if (candidate !== undefined && candidate.length > 0 && allFilePaths.has(candidate)) { + return candidate; + } + } + return null; +} + +function joinSearch(root: string, target: string): string | undefined { + if (root.length === 0 || root === '.') return collapseRepoPath(target); + return collapseRepoPath(`${root}/${target}`); +} diff --git a/gitnexus/src/core/ingestion/languages/c/interpret.ts b/gitnexus/src/core/ingestion/languages/c/interpret.ts index e5a15f2fe..4d1c3c9ef 100644 --- a/gitnexus/src/core/ingestion/languages/c/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/c/interpret.ts @@ -8,10 +8,11 @@ export function interpretCImport(captures: CaptureMatch): ParsedImport | null { const source = captures['@import.source']?.text; if (source === undefined) return null; - // System headers (e.g. ) are not resolved to local files - if (captures['@import.system'] !== undefined) return null; - - return { kind: 'wildcard', targetRaw: source }; + return { + kind: 'wildcard', + targetRaw: source, + isSystem: captures['@import.system'] !== undefined, + }; } /** diff --git a/gitnexus/src/core/ingestion/languages/c/resolution-config.ts b/gitnexus/src/core/ingestion/languages/c/resolution-config.ts new file mode 100644 index 000000000..0ef116d37 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/c/resolution-config.ts @@ -0,0 +1,955 @@ +/** + * Per-workspace C/C++ include config — the analog of Objective-C's header + * scan plus the project files clangd already reads. + * + * Loaded once per analyze pass and threaded into `resolveCImportTarget`. + * `#include ` joins the target onto `headerSearchPaths` and never + * suffix-matches a same-named file under `src/`. `#include "util.h"` may + * still look next to the importer and in the basename index. + * + * Config is read in every directory, not only the root, and a file takes + * the nearest one — how clangd finds `compile_commands.json` and + * `compile_flags.txt` in a monorepo. CMake `include_directories` and + * `target_include_directories` are read too. Make, Meson, and Bazel are not + * parsed; `compile_commands.json` is the compilation database they emit. + */ + +import { existsSync, readdirSync, readFileSync, type Dirent } from 'fs'; +import { isAbsolute, join, relative, resolve } from 'path'; +import { load as loadYaml } from 'js-yaml'; + +export const C_HEADER_EXTENSIONS: ReadonlySet = new Set(['.h']); +export const CPP_HEADER_EXTENSIONS: ReadonlySet = new Set([ + '.h', + '.hpp', + '.hxx', + '.hh', + '.cuh', +]); + +const IMPLICIT_INCLUDE_DIRECTORIES = new Set(['include', 'Headers', 'inc']); + +const SKIP_DIRECTORIES = new Set([ + 'node_modules', + '.git', + 'vendor', + 'dist', + 'build', + 'out', + 'target', + '_build', + '.next', + 'debug', + 'release', + 'bazel-out', + 'bazel-bin', + 'bazel-testlogs', + 'buck-out', +]); + +/** clangd's well-known locations. One file each — the build tree is not walked. */ +const COMPILE_COMMANDS_CANDIDATES = [ + 'compile_commands.json', + '.vscode/compile_commands.json', + 'build/compile_commands.json', + 'out/compile_commands.json', + 'debug/compile_commands.json', + 'release/compile_commands.json', +]; + +/** Include roots for one translation unit, or for files with no database entry. */ +export interface CTranslationUnitPaths { + /** `-I` / `-isystem` / `/I`. Implicit `include` / `Headers` / `inc` only when nothing is declared. */ + readonly headerSearchPaths: readonly string[]; + /** `-iquote`. Quoted includes only. */ + readonly userHeaderSearchPaths: readonly string[]; +} + +const EMPTY_TRANSLATION_UNITS: ReadonlyMap = new Map(); +const EMPTY_DIRECTORY_SCOPES: ReadonlyMap = new Map(); + +export interface CFamilyResolutionConfig { + /** In-repo headers the scan found. Build trees are not included. */ + readonly headers: ReadonlySet; + /** + * Roots for a file that has no `compile_commands.json` entry. + * Another translation unit's `-I` list is not copied here. + */ + readonly headerSearchPaths: readonly string[]; + /** `-iquote` roots for a file that has no compilation-database entry. */ + readonly userHeaderSearchPaths: readonly string[]; + /** + * Per source file, keyed by repo-relative path. Built once per pass. + * Lookup is a map read; the lists are not rebuilt per include. + */ + readonly translationUnits: ReadonlyMap; + /** + * Roots for a file with no database entry below the repo root, keyed by the + * directory whose config (or CMakeLists.txt) set them. A file takes its + * nearest ancestor's entry, else the top-level lists. + */ + readonly directoryScopes: ReadonlyMap; + /** + * Interface include roots a source file sees because its CMake target + * links another target. Keyed by repo-relative source path. + */ + readonly cmakeLinkHeaders?: ReadonlyMap; +} + +export function coerceCFamilyResolutionConfig(value: unknown): CFamilyResolutionConfig | undefined { + if (value == null) return undefined; + if (value instanceof Set) { + return { + headers: value as ReadonlySet, + headerSearchPaths: [], + userHeaderSearchPaths: [], + translationUnits: EMPTY_TRANSLATION_UNITS, + directoryScopes: EMPTY_DIRECTORY_SCOPES, + }; + } + if (typeof value !== 'object') return undefined; + const record = value as Partial; + if (!(record.headers instanceof Set)) return undefined; + return { + headers: record.headers, + headerSearchPaths: record.headerSearchPaths ?? [], + userHeaderSearchPaths: record.userHeaderSearchPaths ?? [], + translationUnits: + record.translationUnits instanceof Map ? record.translationUnits : EMPTY_TRANSLATION_UNITS, + directoryScopes: + record.directoryScopes instanceof Map ? record.directoryScopes : EMPTY_DIRECTORY_SCOPES, + cmakeLinkHeaders: record.cmakeLinkHeaders instanceof Map ? record.cmakeLinkHeaders : undefined, + }; +} + +/** + * The file set a resolver should hand to the include lookup. + * + * `augment` is the language's own per-pass memo (`augmentedFilePathsFor`). + * When there is nothing to union, `allFilePaths` is returned as the same + * object — a copy would be a new memo key on every include. + */ +export function cFamilyImportFiles( + allFilePaths: ReadonlySet, + resolutionConfig: unknown, + augment: (headers: ReadonlySet) => ReadonlySet, +): { readonly files: ReadonlySet; readonly config: CFamilyResolutionConfig | undefined } { + const config = coerceCFamilyResolutionConfig(resolutionConfig); + const headers = config?.headers; + const files = headers !== undefined && headers.size > 0 ? augment(headers) : allFilePaths; + return { files, config }; +} + +export function scanCFamilyHeaders( + repoPath: string, + headerExtensions: ReadonlySet, +): { + readonly headers: ReadonlySet; + readonly implicitRoots: readonly string[]; + readonly configDirectories: readonly string[]; +} { + const headers = new Set(); + const implicit: string[] = []; + const configDirectories: string[] = []; + walk(repoPath, repoPath, headerExtensions, headers, implicit, configDirectories); + return { headers, implicitRoots: sortByDepth(implicit), configDirectories }; +} + +/** What a directory hands down to its subdirectories, clangd- and CMake-style. */ +interface InheritedScope { + /** Nearest `compile_flags.txt` / `.ccls` / `includePath`. Empty under a database. */ + readonly flagHeader: readonly string[]; + readonly flagUser: readonly string[]; + /** `.clangd` `CompileFlags.Add` from every ancestor, outermost first. */ + readonly clangdHeader: readonly string[]; + readonly clangdUser: readonly string[]; + /** `include_directories` and PRIVATE / PUBLIC target roots from ancestor CMakeLists. */ + readonly cmake: readonly string[]; + /** + * A flag file, `.ccls`, or concrete `includePath` in this directory or an + * ancestor claimed the scope. An empty claim is not "no config": `finish` + * must not invent implicit `include/` roots for it. + */ + readonly flagsClaimed: boolean; + /** Absolute dirs for `${PROJECT_SOURCE_DIR}` and `${CMAKE_SOURCE_DIR}`. */ + readonly projectDir: string; + readonly cmakeRootDir: string | undefined; +} + +export function loadCFamilyResolutionConfig( + repoPath: string, + headerExtensions: ReadonlySet, +): CFamilyResolutionConfig { + const { headers, implicitRoots, configDirectories } = scanCFamilyHeaders( + repoPath, + headerExtensions, + ); + + const rootScope: InheritedScope = { + flagHeader: [], + flagUser: [], + clangdHeader: [], + clangdUser: [], + cmake: [], + projectDir: repoPath, + cmakeRootDir: undefined, + flagsClaimed: false, + }; + const scopes = new Map([['', rootScope]]); + const cmakeBits: CmakeBit[] = []; + const databases: { readonly depth: number; readonly units: ParsedUnits }[] = []; + const parsedDatabases = new Map(); + + // Pre-order: every parent is settled before its children. + for (const dir of configDirectories) { + const parent = nearestScope(scopes, dir) ?? rootScope; + const dirAbs = dir.length === 0 ? repoPath : join(repoPath, dir); + + const clangd = readClangd(dirAbs); + const vscode = readVscodeProperties(dirAbs, repoPath); + const database = findCompileCommands( + dirAbs, + repoPath, + clangd.databaseDir, + vscode.compileCommands, + ); + // Two directories can point at one database; parse it once, still honor it in both. + let parsed: ParsedUnits | undefined; + if (database !== undefined) { + const seen = parsedDatabases.has(database); + parsed = seen ? parsedDatabases.get(database) : parseCompileCommands(database, repoPath); + parsedDatabases.set(database, parsed); + if (!seen && parsed !== undefined) databases.push({ depth: depthOf(dir), units: parsed }); + } + + const clangdHeader = [...parent.clangdHeader]; + const clangdUser = [...parent.clangdUser]; + collectIncludeArgs(clangd.add, dirAbs, repoPath, clangdHeader, clangdUser); + + // clangd: the nearest directory with a database or flag file wins, and a + // database beats a flag file in the same directory. A file the database + // does not list gets no flags from here — not an ancestor's flag file. + let flagHeader = parent.flagHeader; + let flagUser = parent.flagUser; + let flagsClaimed = parent.flagsClaimed; + if (parsed !== undefined) { + flagHeader = []; + flagUser = []; + flagsClaimed = false; + } else { + const header: string[] = []; + const user: string[] = []; + let sawConcreteInclude = false; + for (const includePath of vscode.includePaths) { + if (isGlobIncludePath(includePath)) continue; + const rel = toRepoRelative(includePath, dirAbs, repoPath); + if (rel !== undefined) { + header.push(rel); + sawConcreteInclude = true; + } + } + const hasFlags = collectFlagFile( + join(dirAbs, 'compile_flags.txt'), + dirAbs, + repoPath, + header, + user, + ); + const hasCcls = collectFlagFile(join(dirAbs, '.ccls'), dirAbs, repoPath, header, user); + const hasFlagFile = hasFlags || hasCcls || sawConcreteInclude; + if (hasFlagFile) { + flagHeader = header; + flagUser = user; + flagsClaimed = true; + } + } + + const cmake = readCMakeLists(dirAbs, repoPath, parent); + cmakeBits.push(cmake); + + scopes.set(dir, { + flagHeader, + flagUser, + clangdHeader, + clangdUser, + cmake: cmake.scoped.length === 0 ? parent.cmake : [...parent.cmake, ...cmake.scoped], + projectDir: cmake.projectDir ?? parent.projectDir, + cmakeRootDir: parent.cmakeRootDir ?? cmake.cmakeRootDir, + flagsClaimed, + }); + } + + // Declared roots win. The implicit `include/` guess is only for a file no + // config speaks for; the compiler would not search those directories either. + const finish = (scope: InheritedScope): CTranslationUnitPaths => { + const header = uniquePaths([...scope.flagHeader, ...scope.clangdHeader, ...scope.cmake]); + return { + headerSearchPaths: header.length > 0 ? header : scope.flagsClaimed ? [] : implicitRoots, + userHeaderSearchPaths: uniquePaths([...scope.flagUser, ...scope.clangdUser]), + }; + }; + + const directoryScopes = new Map(); + for (const [dir, scope] of scopes) { + if (dir.length > 0) directoryScopes.set(dir, finish(scope)); + } + + // The nearest database lists a file first; a shallower one does not override it. + const translationUnits = new Map(); + databases.sort((left, right) => right.depth - left.depth); + for (const { units } of databases) { + for (const [file, paths] of units) { + if (translationUnits.has(file)) continue; + const scope = nearestScope(scopes, file) ?? rootScope; + translationUnits.set(file, { + headerSearchPaths: uniquePaths([...paths.header, ...scope.clangdHeader]), + userHeaderSearchPaths: uniquePaths([...paths.user, ...scope.clangdUser]), + }); + } + } + + const root = finish(scopes.get('') ?? rootScope); + return { + headers, + headerSearchPaths: root.headerSearchPaths, + userHeaderSearchPaths: root.userHeaderSearchPaths, + translationUnits, + directoryScopes, + cmakeLinkHeaders: cmakeUsageBySource(cmakeBits), + }; +} + +/** The scope of the deepest directory at or above `path`'s directory. */ +export function nearestScope(scopes: ReadonlyMap, path: string): T | undefined { + let dir = parentDirectory(normalizeRepoPath(path)); + for (;;) { + const scope = scopes.get(dir); + if (scope !== undefined) return scope; + if (dir.length === 0) return undefined; + dir = parentDirectory(dir); + } +} + +function parentDirectory(path: string): string { + const slash = path.lastIndexOf('/'); + return slash === -1 ? '' : path.slice(0, slash); +} + +function depthOf(dir: string): number { + return dir.length === 0 ? 0 : dir.split('/').length; +} + +/** Shallower include roots first, then lexicographic. `deps/include` must not beat `include`. */ +function sortByDepth(paths: readonly string[]): string[] { + return [...paths].sort((left, right) => { + const leftDepth = depthOf(left); + const rightDepth = depthOf(right); + if (leftDepth !== rightDepth) return leftDepth - rightDepth; + if (left < right) return -1; + if (left > right) return 1; + return 0; + }); +} + +/** Entries that make a directory worth reading config from. Build dirs may hold a database. */ +const CONFIG_FILE_NAMES = new Set([ + 'compile_commands.json', + 'compile_flags.txt', + '.ccls', + '.clangd', + 'CMakeLists.txt', +]); +const DATABASE_DIRECTORY_NAMES = new Set(['.vscode', 'build', 'out', 'debug', 'release']); + +function walk( + dir: string, + root: string, + headerExtensions: ReadonlySet, + headers: Set, + implicit: string[], + configDirectories: string[], +): void { + let entries: Dirent[]; + try { + entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return; + } + const relativeHere = normalizeRepoPath(relative(root, dir)); + if ( + entries.some((entry) => + entry.isDirectory() + ? DATABASE_DIRECTORY_NAMES.has(entry.name) + : CONFIG_FILE_NAMES.has(entry.name), + ) + ) { + configDirectories.push(relativeHere); + } + for (const entry of entries) { + const name = entry.name; + const full = join(dir, name); + if (entry.isDirectory()) { + if (shouldSkipDirectory(name)) continue; + const relativeDir = normalizeRepoPath(relative(root, full)); + if (IMPLICIT_INCLUDE_DIRECTORIES.has(name)) implicit.push(relativeDir); + walk(full, root, headerExtensions, headers, implicit, configDirectories); + } else if (entry.isFile()) { + const dot = name.lastIndexOf('.'); + const ext = dot === -1 ? '' : name.slice(dot); + if (headerExtensions.has(ext)) { + headers.add(normalizeRepoPath(relative(root, full))); + } + } + } +} + +function shouldSkipDirectory(name: string): boolean { + return SKIP_DIRECTORIES.has(name) || name.startsWith('cmake-build'); +} + +function findCompileCommands( + dirAbs: string, + repoPath: string, + clangdDatabaseDir: string | undefined, + vscodeCompileCommands: string | undefined, +): string | undefined { + if (clangdDatabaseDir !== undefined) { + const pointed = compileCommandsFile(dirAbs, repoPath, clangdDatabaseDir); + if (pointed !== undefined) return pointed; + } + for (const rel of COMPILE_COMMANDS_CANDIDATES) { + const full = join(dirAbs, rel); + if (existsSync(full)) return full; + } + if (vscodeCompileCommands !== undefined && existsSync(vscodeCompileCommands)) { + return vscodeCompileCommands; + } + return undefined; +} + +function compileCommandsFile( + dirAbs: string, + repoPath: string, + databaseDir: string, +): string | undefined { + const rel = toRepoRelative(databaseDir, dirAbs, repoPath); + if (rel === undefined) return undefined; + const full = join(repoPath, rel, 'compile_commands.json'); + return existsSync(full) ? full : undefined; +} + +interface ClangdFlags { + readonly add: readonly string[]; + readonly databaseDir?: string; +} + +function readClangd(dirAbs: string): ClangdFlags { + const text = readText(join(dirAbs, '.clangd')); + if (text.length === 0) return { add: [] }; + let doc: unknown; + try { + doc = loadYaml(text); + } catch { + return { add: [] }; + } + if (doc === null || typeof doc !== 'object') return { add: [] }; + const flags = recordCompileFlags(doc); + const nested = flags?.CompilationDatabase; + const topLevel = (doc as { CompilationDatabase?: unknown }).CompilationDatabase; + const databaseDir = + typeof nested === 'string' ? nested : typeof topLevel === 'string' ? topLevel : undefined; + return { add: clangdAddFlags(flags?.Add), databaseDir }; +} + +function clangdAddFlags(add: unknown): string[] { + if (typeof add === 'string') return splitCommand(add); + if (!Array.isArray(add)) return []; + return add.filter((flag): flag is string => typeof flag === 'string'); +} + +function recordCompileFlags( + doc: object, +): { Add?: unknown; CompilationDatabase?: unknown } | undefined { + const flags = (doc as { CompileFlags?: unknown }).CompileFlags; + if (flags === null || typeof flags !== 'object') return undefined; + return flags as { Add?: unknown; CompilationDatabase?: unknown }; +} + +/** `${workspaceFolder}/**` is a recursive glob, not a search root. */ +function isGlobIncludePath(raw: string): boolean { + const substituted = raw.replaceAll('${workspaceFolder}', '').replaceAll('${workspaceRoot}', ''); + return substituted.includes('*'); +} + +interface VscodeProperties { + readonly includePaths: readonly string[]; + readonly compileCommands?: string; +} + +function readVscodeProperties(dirAbs: string, repoPath: string): VscodeProperties { + const text = readText(join(dirAbs, '.vscode', 'c_cpp_properties.json')); + if (text.length === 0) return { includePaths: [] }; + let doc: unknown; + try { + doc = JSON.parse(text); + } catch { + return { includePaths: [] }; + } + if (doc === null || typeof doc !== 'object') return { includePaths: [] }; + const configurations = (doc as { configurations?: unknown }).configurations; + if (!Array.isArray(configurations)) return { includePaths: [] }; + + const includePaths: string[] = []; + let compileCommands: string | undefined; + for (const configuration of configurations) { + if (configuration === null || typeof configuration !== 'object') continue; + const record = configuration as { includePath?: unknown; compileCommands?: unknown }; + if (Array.isArray(record.includePath)) { + for (const entry of record.includePath) { + if (typeof entry === 'string') includePaths.push(entry); + } + } + if (compileCommands === undefined && typeof record.compileCommands === 'string') { + const resolved = resolvePointedPath(record.compileCommands, dirAbs, repoPath); + if (resolved !== undefined) compileCommands = resolved; + } + } + return { includePaths, compileCommands }; +} + +function resolvePointedPath(raw: string, dirAbs: string, repoPath: string): string | undefined { + const rel = toRepoRelative(raw, dirAbs, repoPath); + if (rel === undefined) return undefined; + return rel.length === 0 ? repoPath : join(repoPath, rel); +} + +const CMAKE_INCLUDE_COMMAND = + /(?:^|[^\w])(include_directories|target_include_directories)\s*\(([^)]*)\)/gi; +const CMAKE_TARGET_COMMAND = /(?:^|[^\w])(add_library|add_executable)\s*\(([^)]*)\)/gi; +const CMAKE_LINK_COMMAND = /(?:^|[^\w])target_link_libraries\s*\(([^)]*)\)/gi; +const CMAKE_PROJECT_COMMAND = /(?:^|[^\w])project\s*\(/i; +const CMAKE_ORDER_KEYWORDS = new Set(['SYSTEM', 'BEFORE', 'AFTER']); + +/** + * Drop CMake line comments and bracket comments (`#[[ ... ]]`) without + * eating `#` inside a quoted argument. + */ +function stripCMakeComments(text: string): string { + let out = ''; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (ch === '"') { + const start = i; + i += 1; + while (i < text.length && text[i] !== '"') { + if (text[i] === '\\') i += 1; + i += 1; + } + if (i < text.length) i += 1; + out += text.slice(start, i); + continue; + } + if (ch === '#') { + const bracket = text.slice(i + 1).match(/^\[(=*)\[/); + if (bracket !== null) { + const close = `]${bracket[1]}]`; + const bodyStart = i + 1 + bracket[0].length; + const end = text.indexOf(close, bodyStart); + i = end === -1 ? text.length : end + close.length; + continue; + } + const nl = text.indexOf('\n', i); + i = nl === -1 ? text.length : nl; + continue; + } + out += ch; + i += 1; + } + return out; +} + +interface CmakeBit { + readonly scoped: readonly string[]; + readonly projectDir: string | undefined; + readonly cmakeRootDir: string | undefined; + readonly targets: readonly { readonly name: string; readonly sources: readonly string[] }[]; + readonly interfaceIncludes: readonly { readonly target: string; readonly path: string }[]; + readonly links: readonly { + readonly target: string; + readonly dep: string; + readonly propagate: boolean; + }[]; +} + +const CMAKE_TARGET_KEYWORDS = new Set([ + 'STATIC', + 'SHARED', + 'MODULE', + 'OBJECT', + 'INTERFACE', + 'ALIAS', + 'IMPORTED', + 'GLOBAL', + 'EXCLUDE_FROM_ALL', +]); +const CMAKE_LINK_SKIP = new Set(['debug', 'optimized', 'general']); + +/** + * Include roots one CMakeLists.txt declares. + * + * `include_directories` and PRIVATE/PUBLIC target roots reach this directory's + * subtree (PUBLIC is also an interface root). INTERFACE roots and linked + * targets' interface roots reach only the dependents recorded by + * `target_link_libraries`. A path left with `${VAR}` or a generator expression + * is dropped, not guessed. + */ +function readCMakeLists(dirAbs: string, repoPath: string, parent: InheritedScope): CmakeBit { + const text = readText(join(dirAbs, 'CMakeLists.txt')); + if (text.length === 0) { + return { + scoped: [], + projectDir: undefined, + cmakeRootDir: undefined, + targets: [], + interfaceIncludes: [], + links: [], + }; + } + const source = stripCMakeComments(text); + const projectDir = CMAKE_PROJECT_COMMAND.test(source) ? dirAbs : undefined; + const variables = new Map([ + ['CMAKE_CURRENT_SOURCE_DIR', dirAbs], + ['CMAKE_CURRENT_LIST_DIR', dirAbs], + ['PROJECT_SOURCE_DIR', projectDir ?? parent.projectDir], + ['CMAKE_SOURCE_DIR', parent.cmakeRootDir ?? dirAbs], + ]); + + const scoped: string[] = []; + const targets: { name: string; sources: string[] }[] = []; + const interfaceIncludes: { target: string; path: string }[] = []; + const links: { target: string; dep: string; propagate: boolean }[] = []; + + for (const match of source.matchAll(CMAKE_TARGET_COMMAND)) { + const parsed = cmakeTargetArgs(match[2] ?? '', dirAbs, repoPath); + if (parsed.name !== undefined) targets.push(parsed); + } + for (const match of source.matchAll(CMAKE_LINK_COMMAND)) { + links.push(...cmakeLinkArgs(match[1] ?? '')); + } + + for (const match of source.matchAll(CMAKE_INCLUDE_COMMAND)) { + const isTarget = match[1]?.toLowerCase() === 'target_include_directories'; + const args = cmakeArgTokens(match[2] ?? ''); + const targetName = isTarget ? args[0] : undefined; + let visibility = 'PRIVATE'; + for (const arg of isTarget ? args.slice(1) : args) { + if (CMAKE_ORDER_KEYWORDS.has(arg)) continue; + if (arg === 'PUBLIC' || arg === 'PRIVATE' || arg === 'INTERFACE') { + visibility = arg; + continue; + } + const expanded = expandCMakePath(arg, variables); + if (expanded === undefined) continue; + const rel = toRepoRelative(expanded, dirAbs, repoPath); + if (rel === undefined) continue; + if (visibility !== 'INTERFACE') scoped.push(rel); + if (isTarget && targetName !== undefined && visibility !== 'PRIVATE') { + interfaceIncludes.push({ target: targetName, path: rel }); + } + } + } + return { scoped, projectDir, cmakeRootDir: dirAbs, targets, interfaceIncludes, links }; +} + +function cmakeArgTokens(body: string): string[] { + return [...body.matchAll(/"([^"]*)"|(\S+)/g)].map((arg) => arg[1] ?? arg[2] ?? ''); +} + +function cmakeTargetArgs( + body: string, + dirAbs: string, + repoPath: string, +): { name: string | undefined; sources: string[] } { + let name: string | undefined; + const sources: string[] = []; + for (const arg of cmakeArgTokens(body)) { + if (CMAKE_TARGET_KEYWORDS.has(arg)) continue; + if (name === undefined) { + name = arg; + continue; + } + if (!arg.includes('/') && !arg.includes('.')) continue; + const rel = toRepoRelative(arg, dirAbs, repoPath); + if (rel !== undefined) sources.push(rel); + } + return { name, sources }; +} + +function cmakeLinkArgs(body: string): { target: string; dep: string; propagate: boolean }[] { + const args = cmakeArgTokens(body); + const target = args[0]; + if (target === undefined) return []; + let propagate = true; + const links: { target: string; dep: string; propagate: boolean }[] = []; + for (const arg of args.slice(1)) { + if (arg === 'PRIVATE' || arg === 'PUBLIC' || arg === 'INTERFACE') { + propagate = arg !== 'PRIVATE'; + continue; + } + if (CMAKE_LINK_SKIP.has(arg) || arg.startsWith('$') || arg.startsWith('-')) continue; + links.push({ target, dep: arg, propagate }); + } + return links; +} + +/** Interface include roots each source file sees through `target_link_libraries`. */ +function cmakeUsageBySource(bits: readonly CmakeBit[]): ReadonlyMap { + const sources = new Map(); + const interfaceIncludes = new Map(); + const links = new Map(); + for (const bit of bits) { + for (const target of bit.targets) { + const list = sources.get(target.name) ?? []; + list.push(...target.sources); + sources.set(target.name, list); + } + for (const include of bit.interfaceIncludes) { + const list = interfaceIncludes.get(include.target) ?? []; + list.push(include.path); + interfaceIncludes.set(include.target, list); + } + for (const link of bit.links) { + const list = links.get(link.target) ?? []; + list.push({ dep: link.dep, propagate: link.propagate }); + links.set(link.target, list); + } + } + + const usageOf = (name: string): string[] => { + const out: string[] = []; + const seen = new Set(); + const addInterface = (dep: string): void => { + if (seen.has(dep)) return; + seen.add(dep); + out.push(...(interfaceIncludes.get(dep) ?? [])); + for (const next of links.get(dep) ?? []) { + if (next.propagate) addInterface(next.dep); + } + }; + for (const link of links.get(name) ?? []) addInterface(link.dep); + return [...new Set(out)]; + }; + + const bySource = new Map(); + for (const [name, files] of sources) { + const unique = [...new Set(usageOf(name))]; + if (unique.length === 0) continue; + for (const file of files) bySource.set(file, unique); + } + return bySource; +} + +function expandCMakePath( + token: string, + variables: ReadonlyMap, +): string | undefined { + const buildInterface = /^\$$/.exec(token); + const path = buildInterface?.[1] ?? token; + if (path.includes('$<')) return undefined; + return path.replace(/\$\{(\w+)\}/g, (whole, name: string) => variables.get(name) ?? whole); +} + +type ParsedUnits = Map; + +/** + * Per-file include roots. `undefined` means the file exists but is not a + * usable compilation database, so callers must fall through to flag files. + */ +function parseCompileCommands(filePath: string, repoPath: string): ParsedUnits | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(readText(filePath)); + } catch { + return undefined; + } + if (!Array.isArray(parsed)) return undefined; + const units: ParsedUnits = new Map(); + for (const entry of parsed) { + if (entry === null || typeof entry !== 'object') continue; + const record = entry as { + directory?: unknown; + command?: unknown; + arguments?: unknown; + file?: unknown; + }; + if (typeof record.file !== 'string' || record.file.length === 0) continue; + const args = Array.isArray(record.arguments) + ? record.arguments.filter((arg): arg is string => typeof arg === 'string') + : typeof record.command === 'string' + ? splitCommand(record.command) + : []; + const directory = typeof record.directory === 'string' ? record.directory : repoPath; + const base = isAbsolute(directory) ? directory : join(repoPath, directory); + const fileRel = toRepoRelative(record.file, base, repoPath); + // First entry for a file wins. A later command must not merge its -I list in. + if (fileRel === undefined || units.has(fileRel)) continue; + const header: string[] = []; + const user: string[] = []; + collectIncludeArgs(args, base, repoPath, header, user); + units.set(fileRel, { header, user }); + } + return units; +} + +/** Reads one flag file. Returns whether it exists, so an empty one still claims its directory. */ +function collectFlagFile( + filePath: string, + baseDir: string, + repoPath: string, + header: string[], + user: string[], +): boolean { + if (!existsSync(filePath)) return false; + const text = readText(filePath); + const args: string[] = []; + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0 || line.startsWith('#') || line.startsWith('%')) continue; + args.push(line); + } + collectIncludeArgs(args, baseDir, repoPath, header, user); + return true; +} + +function collectIncludeArgs( + args: readonly string[], + baseDir: string, + repoPath: string, + header: string[], + user: string[], +): void { + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === undefined) continue; + const eaten = takeIncludeFlag(arg, args[i + 1]); + if (eaten === undefined) continue; + if (eaten.consumedNext) i++; + const rel = toRepoRelative(eaten.path, baseDir, repoPath); + if (rel === undefined) continue; + (eaten.quotedOnly ? user : header).push(rel); + } +} + +function takeIncludeFlag( + arg: string, + next: string | undefined, +): + | { readonly path: string; readonly quotedOnly: boolean; readonly consumedNext: boolean } + | undefined { + if (arg === '-I' || arg === '-isystem' || arg === '/I' || arg === '-iquote') { + if (next === undefined || next.startsWith('-')) return undefined; + return { path: next, quotedOnly: arg === '-iquote', consumedNext: true }; + } + if (arg.startsWith('-I')) { + return { path: arg.slice(2), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('/I') && arg.length > 2) { + return { path: arg.slice(2), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('-isystem') && arg.length > '-isystem'.length) { + return { path: arg.slice('-isystem'.length), quotedOnly: false, consumedNext: false }; + } + if (arg.startsWith('-iquote') && arg.length > '-iquote'.length) { + return { path: arg.slice('-iquote'.length), quotedOnly: true, consumedNext: false }; + } + return undefined; +} + +/** Split a compiler command the way `arguments` already is. Quotes are kept out of the tokens. */ +function splitCommand(command: string): string[] { + const args: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + for (let i = 0; i < command.length; i++) { + const ch = command[i]; + if (quote !== null) { + if (ch === quote) quote = null; + else current += ch; + continue; + } + if (ch === '"' || ch === "'") { + quote = ch; + continue; + } + if (ch === '\\' && i + 1 < command.length) { + const next = command[i + 1]; + // A Windows path separator is not a shell escape. Only drop the + // backslash when it quotes the next character. + if (next === '"' || next === "'" || next === '\\' || next === ' ' || next === '\t') { + current += next; + i++; + continue; + } + current += ch; + continue; + } + if (ch === ' ' || ch === '\t') { + if (current.length > 0) { + args.push(current); + current = ''; + } + continue; + } + current += ch ?? ''; + } + if (current.length > 0) args.push(current); + return args; +} + +function toRepoRelative(rawPath: string, baseDir: string, repoPath: string): string | undefined { + let raw = normalizeRepoPath(rawPath.trim()); + if (raw.length === 0) return undefined; + raw = raw.replaceAll('${workspaceFolder}', '.').replaceAll('${workspaceRoot}', '.'); + if (raw.includes('${') || raw.includes('$(')) return undefined; + raw = raw.replace(/\/\*\*$/, '').replace(/\/\*$/, ''); + if (raw.length === 0) raw = '.'; + + const absolute = isAbsolute(raw) || /^[A-Za-z]:/.test(raw) ? raw : join(baseDir, raw); + const rel = normalizeRepoPath(relative(repoPath, resolve(absolute))); + // `..headers` is a directory inside the repo. Only `..` and `../…` leave it. + if (rel === '..' || rel.startsWith('../') || isAbsolute(rel)) return undefined; + return collapseRepoPath(rel); +} + +function uniquePaths(paths: readonly string[]): string[] { + return [...new Set(paths)]; +} + +function readText(filePath: string): string { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return ''; + } +} + +export function normalizeRepoPath(value: string): string { + return value.replaceAll('\\', '/').replace(/^\.\//, ''); +} + +/** Collapse `.` / `..` in a repo-relative path. `..` past the root is a miss, not a clipped path. */ +export function collapseRepoPath(value: string): string | undefined { + const parts = normalizeRepoPath(value).split('/'); + const out: string[] = []; + for (const part of parts) { + if (part === '' || part === '.') continue; + if (part === '..') { + if (out.length === 0) return undefined; + out.pop(); + continue; + } + out.push(part); + } + return out.join('/'); +} diff --git a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts index c3f9fb36f..60fbb085d 100644 --- a/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/c/scope-resolver.ts @@ -4,8 +4,13 @@ import { buildMro, defaultLinearize } from '../../scope-resolution/passes/mro.js import { populateClassOwnedMembers } from '../../scope-resolution/scope/walkers.js'; import type { ScopeResolver } from '../../scope-resolution/contract/scope-resolver.js'; import { cProvider } from '../c-cpp.js'; -import { cArityCompatibility, cMergeBindings, resolveCImportTarget } from './index.js'; -import { scanHeaderFiles } from './header-scan.js'; +import { cArityCompatibility, cMergeBindings } from './index.js'; +import { cIncludeLookupFromConfig, resolveCImportTarget } from './import-target.js'; +import { + C_HEADER_EXTENSIONS, + cFamilyImportFiles, + loadCFamilyResolutionConfig, +} from './resolution-config.js'; import { expandCWildcardNames, isStaticName, clearStaticNames } from './static-linkage.js'; import { applyCStaticLinkageSideChannel } from './capture-side-channel.js'; import { perFileSet } from '../../import-resolvers/per-file-set.js'; @@ -61,7 +66,7 @@ export const cScopeResolver: ScopeResolver = { // Clear stale static-linkage data from any previous invocation to // prevent cross-repo contamination in server-mode scenarios. clearStaticNames(); - return scanHeaderFiles(repoPath); + return loadCFamilyResolutionConfig(repoPath, C_HEADER_EXTENSIONS); }, // Worker-boundary restore (see `ScopeResolver.applyCaptureSideChannel`). @@ -81,19 +86,25 @@ export const cScopeResolver: ScopeResolver = { // this process. Runs BEFORE `populateOwners`. applyCaptureSideChannel: applyCStaticLinkageSideChannel, - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { // Augment allFilePaths with .h files discovered via loadResolutionConfig // since the phase only passes .c files to the C resolver but #include - // targets .h files classified as C++ in language detection. - const headerPaths = resolutionConfig as ReadonlySet | undefined; - if (headerPaths !== undefined && headerPaths.size > 0) { - return resolveCImportTarget( - targetRaw, - fromFile, - augmentedFilePathsFor(allFilePaths)(headerPaths), - ); - } - return resolveCImportTarget(targetRaw, fromFile, allFilePaths); + // targets .h files classified as C++ in language detection. A raw header + // Set (the import-target bench) is the same shape with empty search paths. + // The augmented set is the memo key — pass it through, never copy it. + const { files, config } = cFamilyImportFiles( + allFilePaths, + resolutionConfig, + augmentedFilePathsFor(allFilePaths), + ); + const parsed = context?.parsedImport; + const isSystem = parsed?.kind === 'wildcard' && parsed.isSystem === true; + return resolveCImportTarget( + targetRaw, + fromFile, + files, + cIncludeLookupFromConfig(config, isSystem), + ); }, expandsWildcardTo: (targetModuleScope, parsedFiles) => diff --git a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts index 799c70820..6c3ec445c 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/header-scan.ts @@ -1,53 +1,9 @@ -import { readdirSync, type Dirent } from 'fs'; -import { join, relative } from 'path'; - -/** C++ header extensions to scan for in the workspace. */ -const HEADER_EXTENSIONS = new Set(['.h', '.hpp', '.hxx', '.hh', '.cuh']); +import { CPP_HEADER_EXTENSIONS, scanCFamilyHeaders } from '../c/resolution-config.js'; /** - * Walk `repoPath` recursively and return relative paths of all C++ header files. - * Used by `loadResolutionConfig` so the C++ resolver can resolve `#include` - * targets that live in header files. - * - * Scans for: .h, .hpp, .hxx, .hh, .cuh + * Walk `repoPath` and return relative paths of C++ headers + * (`.h`, `.hpp`, `.hxx`, `.hh`, `.cuh`). */ export function scanCppHeaderFiles(repoPath: string): ReadonlySet { - const headers = new Set(); - walk(repoPath, repoPath, headers); - return headers; -} - -function walk(dir: string, root: string, out: Set): void { - let entries: Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true, encoding: 'utf8' }); - } catch { - return; // permission denied, etc. - } - for (const entry of entries) { - const name = entry.name; - const full = join(dir, name); - if (entry.isDirectory()) { - if ( - name === 'node_modules' || - name === '.git' || - name === 'vendor' || - name === 'dist' || - name === 'build' || - name === 'out' || - name === 'target' || - name === '_build' || - name === '.next' || - name.startsWith('cmake-build') - ) { - continue; - } - walk(full, root, out); - } else if (entry.isFile()) { - const ext = name.slice(name.lastIndexOf('.')); - if (HEADER_EXTENSIONS.has(ext)) { - out.add(relative(root, full).replace(/\\/g, '/')); - } - } - } + return scanCFamilyHeaders(repoPath, CPP_HEADER_EXTENSIONS).headers; } diff --git a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts index 26e317c6e..b6c46b062 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/import-target.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/import-target.ts @@ -1,18 +1,22 @@ -import { resolveCImportTarget } from '../c/import-target.js'; +import { + createCIncludeSuffixIndex, + resolveCFamilyImport, + type CIncludeLookup, +} from '../c/import-target.js'; /** - * Resolve a C++ #include path to a file in the workspace. - * C++ #include path resolution is identical to C: - * 1. Same-directory sibling (relative lookup) - * 2. Exact match - * 3. Suffix match with depth + lexicographic tiebreak - * - * Re-exports the C implementation since the #include semantics are shared. + * C++ `#include` resolution is the C lookup. This module's own suffix + * index is the one thing that is not shared: the memo is keyed on the + * file set, and one map for both languages would hand each the other's + * index when a test passes the same set to both. */ +const cppSuffixIndex = createCIncludeSuffixIndex(); + export function resolveCppImportTarget( targetRaw: string, fromFile: string, allFilePaths: ReadonlySet, + lookup?: CIncludeLookup, ): string | null { - return resolveCImportTarget(targetRaw, fromFile, allFilePaths); + return resolveCFamilyImport(targetRaw, fromFile, allFilePaths, lookup, cppSuffixIndex); } diff --git a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts index e5a3635ca..635b09264 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/interpret.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/interpret.ts @@ -8,15 +8,14 @@ import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'git * 2. using namespace X; → wildcard import (all symbols from namespace X) * 3. using X::name; → named import (single symbol from namespace X) * - * System headers (#include <...>) are not resolved to local files. + * Angle `#include <...>` sets `isSystem`. The resolver searches header + * paths for that form and does not use the basename index. */ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null { const source = captures['@import.source']?.text; if (source === undefined) return null; - // System headers are not resolved to local files - if (captures['@import.system'] !== undefined) return null; - + const isSystem = captures['@import.system'] !== undefined; const kind = captures['@import.kind']?.text; if (kind === 'named') { @@ -27,7 +26,7 @@ export function interpretCppImport(captures: CaptureMatch): ParsedImport | null } // #include or using namespace — wildcard import - return { kind: 'wildcard', targetRaw: source }; + return { kind: 'wildcard', targetRaw: source, isSystem }; } /** diff --git a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts index 9421975ea..e88581063 100644 --- a/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts +++ b/gitnexus/src/core/ingestion/languages/cpp/scope-resolver.ts @@ -15,7 +15,12 @@ import { cppArityCompatibility } from './arity.js'; import { CPP_CONVERSION_ONLY_ARG_TYPE_PREFIXES, cppConversionRank } from './conversion-rank.js'; import { cppMergeBindings } from './merge-bindings.js'; import { resolveCppImportTarget } from './import-target.js'; -import { scanCppHeaderFiles } from './header-scan.js'; +import { cIncludeLookupFromConfig } from '../c/import-target.js'; +import { + CPP_HEADER_EXTENSIONS, + cFamilyImportFiles, + loadCFamilyResolutionConfig, +} from '../c/resolution-config.js'; import { expandCppWildcardNames, isFileLocal, @@ -110,22 +115,26 @@ export const cppScopeResolver: ScopeResolver = { clearCppInlineNamespaces(); clearCppUserDefinedConversions(); clearCppMemberLookupState(); - return scanCppHeaderFiles(repoPath); + return loadCFamilyResolutionConfig(repoPath, CPP_HEADER_EXTENSIONS); }, - resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig) => { - // Augment allFilePaths with header files discovered via loadResolutionConfig. - // C++ .h/.hpp/.hxx/.hh files may be classified differently by language - // detection but are importable from .cpp files via #include. - const headerPaths = resolutionConfig as ReadonlySet | undefined; - if (headerPaths !== undefined && headerPaths.size > 0) { - return resolveCppImportTarget( - targetRaw, - fromFile, - augmentedFilePathsFor(allFilePaths)(headerPaths), - ); - } - return resolveCppImportTarget(targetRaw, fromFile, allFilePaths); + resolveImportTarget: (targetRaw, fromFile, allFilePaths, resolutionConfig, context) => { + // Same adapter as C, with this file's own augmented-set memo. C++ + // `#include` then goes through `resolveCppImportTarget`, whose suffix + // index is also private to C++. + const { files, config } = cFamilyImportFiles( + allFilePaths, + resolutionConfig, + augmentedFilePathsFor(allFilePaths), + ); + const parsed = context?.parsedImport; + const isSystem = parsed?.kind === 'wildcard' && parsed.isSystem === true; + return resolveCppImportTarget( + targetRaw, + fromFile, + files, + cIncludeLookupFromConfig(config, isSystem), + ); }, expandsWildcardTo: (targetModuleScope, parsedFiles) => diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 6c75bf39d..c3e8fc1b5 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -787,7 +787,10 @@ import { copyV8CacheIfPresent, tryLoadV8Cache, writeV8CacheFile } from './v8-sid // Ruby multi-statement `if` one opaque source. // v113 (#3371): `.ipynb` code cells are extracted to Python before parse. // Warm caches keyed on raw JSON would replay empty/failed Python parses. -const SCHEMA_BUMP = 113; +// v114 (#2965): C and C++ angle includes are real wildcard imports with +// `isSystem`. Warm shards stored those captures as absent, so incremental +// analyze never asked the resolver to search include paths. 113 is #3371. +const SCHEMA_BUMP = 114; const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from diff --git a/gitnexus/test/unit/incremental-parse-cache.test.ts b/gitnexus/test/unit/incremental-parse-cache.test.ts index c08498613..d949208d1 100644 --- a/gitnexus/test/unit/incremental-parse-cache.test.ts +++ b/gitnexus/test/unit/incremental-parse-cache.test.ts @@ -291,8 +291,11 @@ describe('PARSE_CACHE_VERSION', () => { // Moved 104 -> 112 for #3354: callable-value flow follows `??`/`||`/`?:` // branches. 105-111 are claimed by open PR #3326. // Moved 112 -> 113 for #3371: notebook code-cell extraction before Python parse. - it('pins SCHEMA_BUMP to 113 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371)', () => { - expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(113); + // Moved 113 -> 114 for #2965: C/C++ angle includes survive interpret as + // `isSystem` wildcards. Warm shards omitted them, so include-path lookup + // never ran until a full reparse. 113 stays taken by #3371. + it('pins SCHEMA_BUMP to 114 so concurrent bumps cannot silently collide (#2766, #3015, #3088, #2885, #3128, #2865, #3130, #1432, #3161, #3179, #3219, #3190, #3253, #3273, #3339, #3354, #3371, #2965)', () => { + expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).toBe(114); expect(PARSE_CACHE_BUCKET_COUNT).toBe(128); // The PREVIOUS version must fail the reuse gate, not merely differ from the // current one — a hardcoded number outside the conflict hunk rebases cleanly @@ -301,7 +304,7 @@ describe('PARSE_CACHE_VERSION', () => { for (const taken of [ 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102, 103, - 104, 105, 106, 107, 108, 109, 110, 111, 112, + 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, ]) { expect(Number(PARSE_CACHE_VERSION.split('+', 1)[0])).not.toBe(taken); } diff --git a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts index 549e651bb..ba9580341 100644 --- a/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/c/c-imports.test.ts @@ -57,16 +57,16 @@ describe('C import interpretation (interpretCImport)', () => { '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'header.h'), }); - expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h' }); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.h', isSystem: false }); }); - it('returns null for system headers', () => { + it('keeps the angle-bracket flag for system headers', () => { const result = interpretCImport({ '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'stdio.h'), '@import.system': capt('@import.system', 'true'), }); - expect(result).toBeNull(); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'stdio.h', isSystem: true }); }); it('returns null when @import.source is missing', () => { @@ -154,6 +154,30 @@ describe('C import target resolution (resolveCImportTarget)', () => { expect(result).toBe('lib/missing.h'); }); + it('resolves an angle include only on a header search path', () => { + const files = new Set(['src/stdio.h', 'include/util.h', 'src/main.c']); + expect( + resolveCImportTarget('util.h', 'src/main.c', files, { + isSystem: true, + headerSearchPaths: ['include'], + }), + ).toBe('include/util.h'); + expect( + resolveCImportTarget('stdio.h', 'src/main.c', files, { + isSystem: true, + headerSearchPaths: ['include'], + }), + ).toBeNull(); + }); + + it('keeps quoted suffix matching when no search path is declared', () => { + const files = new Set(['src/stdio.h', 'include/util.h']); + expect(resolveCImportTarget('util.h', 'src/main.c', files, { isSystem: false })).toBe( + 'include/util.h', + ); + expect(resolveCImportTarget('stdio.h', 'src/main.c', files, { isSystem: true })).toBeNull(); + }); + it('same-directory sibling with nested target path', () => { // src/foo.c includes "sub/bar.h" — src/sub/bar.h should win const result = resolveCImportTarget( diff --git a/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts new file mode 100644 index 000000000..732f01b46 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/c/c-workspace.test.ts @@ -0,0 +1,670 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'fs'; +import { join } from 'path'; +import { cScopeResolver } from '../../../../src/core/ingestion/languages/c/scope-resolver.js'; +import { scanCppHeaderFiles } from '../../../../src/core/ingestion/languages/cpp/header-scan.js'; +import { cppScopeResolver } from '../../../../src/core/ingestion/languages/cpp/scope-resolver.js'; +import { + C_HEADER_EXTENSIONS, + CPP_HEADER_EXTENSIONS, + loadCFamilyResolutionConfig, +} from '../../../../src/core/ingestion/languages/c/resolution-config.js'; +import type { ImportResolutionContext } from '../../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; + +const TMP = join(__dirname, '__c_workspace_tmp__'); + +function touch(rel: string, contents = ''): void { + const full = join(TMP, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, contents); +} + +function angle(targetRaw: string): ImportResolutionContext { + return { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw, isSystem: true }, + }; +} + +function quoted(targetRaw: string): ImportResolutionContext { + return { + parsedFiles: [], + parsedImport: { kind: 'wildcard', targetRaw, isSystem: false }, + }; +} + +beforeEach(() => { + mkdirSync(TMP, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +describe('C/C++ workspace scan', () => { + it('finds headers, skips build output, and records implicit include roots', () => { + touch('include/util.h'); + touch('Headers/Widget.h'); + touch('inc/local.h'); + touch('src/stdio.h'); + touch('build/generated.h'); + touch('debug/generated.h'); + touch('release/generated.h'); + touch('cmake-build-debug/generated.h'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headers).toContain('include/util.h'); + expect(scanned.headers).toContain('src/stdio.h'); + expect(scanned.headers).not.toContain('build/generated.h'); + expect(scanned.headers).not.toContain('debug/generated.h'); + expect(scanned.headers).not.toContain('release/generated.h'); + expect(scanned.headers).not.toContain('cmake-build-debug/generated.h'); + expect(scanned.headerSearchPaths).toEqual( + expect.arrayContaining(['include', 'Headers', 'inc']), + ); + expect(scanned.headerSearchPaths).not.toContain('src'); + }); + + it('scans C++ header extensions and still skips the build tree', () => { + touch('include/util.hpp'); + touch('src/widget.hh'); + touch('build/generated.hpp'); + const scanned = loadCFamilyResolutionConfig(TMP, CPP_HEADER_EXTENSIONS); + expect(scanCppHeaderFiles(TMP)).toEqual(scanned.headers); + expect(scanned.headers).toContain('include/util.hpp'); + expect(scanned.headers).toContain('src/widget.hh'); + expect(scanned.headers).not.toContain('build/generated.hpp'); + }); + + it('reads compile_commands.json and drops absolute system roots', () => { + touch('include/util.h'); + touch('Headers/Widget.h'); + touch('private/local.h'); + touch('src/stdio.h'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: join(TMP, 'src'), + file: 'main.c', + command: 'gcc -I../include -isystem /usr/include -iquote ../private -c main.c', + }, + { + directory: TMP, + file: 'a.c', + arguments: ['cl', '/I', 'msvc', '/I/usr/include'], + }, + ]), + ); + touch('compile_flags.txt', '-Iignored\n'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const main = scanned.translationUnits.get('src/main.c'); + const other = scanned.translationUnits.get('a.c'); + expect(main?.headerSearchPaths).toContain('include'); + expect(main?.userHeaderSearchPaths).toEqual(['private']); + expect(main?.headerSearchPaths).not.toContain('msvc'); + expect(other?.headerSearchPaths).toContain('msvc'); + expect(other?.headerSearchPaths).not.toContain('private'); + expect(scanned.headerSearchPaths).toContain('Headers'); + expect(scanned.headerSearchPaths).not.toContain('msvc'); + expect(scanned.headerSearchPaths).not.toContain('ignored'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('usr'); + const workspace = new Set([ + 'src/main.c', + 'a.c', + 'include/util.h', + 'msvc/only.h', + 'src/stdio.h', + ]); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + expect( + cScopeResolver.resolveImportTarget( + 'only.h', + 'src/main.c', + workspace, + scanned, + angle('only.h'), + ), + ).toBeNull(); + expect( + cScopeResolver.resolveImportTarget('only.h', 'a.c', workspace, scanned, angle('only.h')), + ).toBe('msvc/only.h'); + expect( + cScopeResolver.resolveImportTarget( + 'stdio.h', + 'src/main.c', + workspace, + scanned, + angle('stdio.h'), + ), + ).toBeNull(); + }); + + it('honors a .clangd CompilationDatabase and still applies CompileFlags.Add', () => { + touch('extras/extra.h'); + touch('fromdb/db.h'); + touch( + '.clangd', + ['CompileFlags:', ' CompilationDatabase: build', ' Add: [-Iextras]', ''].join('\n'), + ); + touch( + 'build/compile_commands.json', + JSON.stringify([ + { + directory: TMP, + arguments: ['gcc', '-Ifromdb', '-c', 'main.c'], + file: 'main.c', + }, + ]), + ); + touch( + 'compile_commands.json', + JSON.stringify([ + { directory: TMP, arguments: ['gcc', '-Iignored-root', '-c', 'main.c'], file: 'main.c' }, + ]), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const main = scanned.translationUnits.get('main.c'); + expect(main?.headerSearchPaths).toEqual(expect.arrayContaining(['fromdb', 'extras'])); + expect(scanned.headerSearchPaths).toContain('extras'); + expect(scanned.headerSearchPaths).not.toContain('fromdb'); + expect(scanned.headerSearchPaths).not.toContain('ignored-root'); + const workspace = new Set(['main.c', 'util.h', 'lib/other.c', 'fromdb/db.h', 'extras/extra.h']); + expect( + cScopeResolver.resolveImportTarget('db.h', 'main.c', workspace, scanned, angle('db.h')), + ).toBe('fromdb/db.h'); + // A header beside the listed translation unit uses that unit's -I. + expect( + cScopeResolver.resolveImportTarget('db.h', 'util.h', workspace, scanned, angle('db.h')), + ).toBe('fromdb/db.h'); + // A file in another directory is not covered by this database entry. + expect( + cScopeResolver.resolveImportTarget('db.h', 'lib/other.c', workspace, scanned, angle('db.h')), + ).toBeNull(); + }); + + it('reads c_cpp_properties.json includePath when no compilation database exists', () => { + touch('src/main.c'); + touch( + '.vscode/c_cpp_properties.json', + JSON.stringify({ + configurations: [ + { + name: 'Linux', + includePath: ['${workspaceFolder}/include', '${workspaceFolder}/**'], + compileCommands: '${workspaceFolder}/missing/compile_commands.json', + }, + ], + }), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toContain('include'); + expect(scanned.headerSearchPaths).not.toContain(''); + expect(scanned.headerSearchPaths).not.toContain('src'); + }); + + it('keeps an in-repo include root named ..headers and still drops a parent escape', () => { + touch('..headers/util.h'); + touch('compile_flags.txt', ['-I..headers', '-I..', '-I../outside'].join('\n')); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toContain('..headers'); + expect(scanned.headerSearchPaths).not.toContain('..'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('../outside'); + const workspace = new Set(['src/main.c', '..headers/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('..headers/util.h'); + }); + + it('reads compile_flags.txt and .ccls as the fallback flag files', () => { + touch( + 'compile_flags.txt', + ['-Iinclude', '-isystem', '/usr/include', '-iquote', 'private'].join('\n'), + ); + touch('.ccls', ['%clang', '-Ithird'].join('\n')); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths).toEqual(expect.arrayContaining(['include', 'third'])); + expect(scanned.userHeaderSearchPaths).toEqual(['private']); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('usr'); + }); +}); + +describe('C/C++ workspace import resolution', () => { + const files = new Set([ + 'src/stdio.h', + 'src/cstdio.h', + 'include/util.h', + 'include/util.hpp', + 'src/main.c', + 'src/main.cpp', + ]); + + it('lets a quoted include resolve and refuses an angle include that only matches by name', () => { + touch('src/stdio.h'); + touch('include/util.h'); + touch('src/main.c'); + const loaded = cScopeResolver.loadResolutionConfig?.(TMP); + expect(loaded).toEqual( + expect.objectContaining({ headerSearchPaths: expect.arrayContaining(['include']) }), + ); + expect( + cScopeResolver.resolveImportTarget('stdio.h', 'src/main.c', files, loaded, angle('stdio.h')), + ).toBeNull(); + expect( + cScopeResolver.resolveImportTarget('util.h', 'src/main.c', files, loaded, quoted('util.h')), + ).toBe('include/util.h'); + }); + + it('resolves an angle include on one declared include root', () => { + const config = { + headers: new Set(['include/util.h', 'src/stdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + expect( + cScopeResolver.resolveImportTarget('util.h', 'src/main.c', files, config, angle('util.h')), + ).toBe('include/util.h'); + expect( + cppScopeResolver.resolveImportTarget( + 'util.hpp', + 'src/main.cpp', + files, + { + headers: new Set(['include/util.hpp', 'src/cstdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }, + angle('util.hpp'), + ), + ).toBe('include/util.hpp'); + expect( + cppScopeResolver.resolveImportTarget( + 'cstdio.h', + 'src/main.cpp', + files, + { + headers: new Set(['include/util.hpp', 'src/cstdio.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }, + angle('cstdio.h'), + ), + ).toBeNull(); + }); + + it('accepts a raw header set so the import-target bench shape still resolves', () => { + const sources = new Set(['src/main.c']); + const headers = new Set(['include/util.h', 'src/stdio.h']); + expect(cScopeResolver.resolveImportTarget('util.h', 'src/main.c', sources, headers)).toBe( + 'include/util.h', + ); + expect( + cScopeResolver.resolveImportTarget( + 'stdio.h', + 'src/main.c', + sources, + headers, + angle('stdio.h'), + ), + ).toBeNull(); + }); + + it('keeps -iquote off the angle search', () => { + const config = { + headers: new Set(['private/util.h']), + headerSearchPaths: [], + userHeaderSearchPaths: ['private'], + }; + const workspace = new Set(['src/main.c', 'private/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + config, + quoted('util.h'), + ), + ).toBe('private/util.h'); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + config, + angle('util.h'), + ), + ).toBeNull(); + }); +}); + +describe('C/C++ include robustness', () => { + it('tries the shallower implicit include root first', () => { + touch('include/util.h'); + touch('deps/include/util.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headerSearchPaths.indexOf('include')).toBeLessThan( + scanned.headerSearchPaths.indexOf('deps/include'), + ); + const workspace = new Set(['src/main.c', 'include/util.h', 'deps/include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('keeps Windows backslashes in a compile_commands command string', () => { + touch('include/util.h'); + touch('src/main.c'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: join(TMP, 'src'), + file: 'main.c', + command: 'gcc -I..\\include -c main.c', + }, + ]), + ); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.translationUnits.get('src/main.c')?.headerSearchPaths).toContain('include'); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('does not treat bazel-out include directories as search roots', () => { + touch('include/api.h'); + touch('bazel-out/k8-fastbuild/bin/include/api.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.headers).toContain('include/api.h'); + expect(scanned.headers).not.toContain('bazel-out/k8-fastbuild/bin/include/api.h'); + expect(scanned.headerSearchPaths).toContain('include'); + expect(scanned.headerSearchPaths.join('\n')).not.toContain('bazel-out'); + const workspace = new Set([ + 'src/main.c', + 'include/api.h', + 'bazel-out/k8-fastbuild/bin/include/api.h', + ]); + expect( + cScopeResolver.resolveImportTarget('api.h', 'src/main.c', workspace, scanned, angle('api.h')), + ).toBe('include/api.h'); + }); + + it('falls through to compile_flags.txt when compile_commands.json is not JSON', () => { + touch('compile_commands.json', '{ this is not json'); + touch('compile_flags.txt', '-Iinclude\n'); + touch('include/util.h'); + touch('src/main.c'); + const scanned = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(scanned.translationUnits.size).toBe(0); + expect(scanned.headerSearchPaths).toContain('include'); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + 'util.h', + 'src/main.c', + workspace, + scanned, + angle('util.h'), + ), + ).toBe('include/util.h'); + }); + + it('does not let an angle include climb out of its search root', () => { + const config = { + headers: new Set(['src/stdio.h', 'include/util.h']), + headerSearchPaths: ['include'], + userHeaderSearchPaths: [], + }; + const workspace = new Set(['src/main.c', 'src/stdio.h', 'include/util.h']); + expect( + cScopeResolver.resolveImportTarget( + '../../src/stdio.h', + 'src/main.c', + workspace, + config, + angle('../../src/stdio.h'), + ), + ).toBeNull(); + }); +}); + +describe('C/C++ monorepo config', () => { + function resolveAngle( + config: ReturnType, + targetRaw: string, + fromFile: string, + workspace: ReadonlySet, + ): string | null { + return cScopeResolver.resolveImportTarget( + targetRaw, + fromFile, + workspace, + config, + angle(targetRaw), + ); + } + + it('reads a compile_commands.json in each sub-project and lets the nearest one win', () => { + touch('libs/a/public/a.h'); + touch('libs/b/api/b.h'); + touch('libs/a/src/a.c'); + touch('libs/b/src/b.c'); + touch( + 'compile_commands.json', + JSON.stringify([ + { directory: TMP, file: 'libs/a/src/a.c', arguments: ['cc', '-Iroot-only', '-c'] }, + ]), + ); + touch( + 'libs/a/compile_commands.json', + JSON.stringify([ + { directory: join(TMP, 'libs/a'), file: 'src/a.c', arguments: ['cc', '-Ipublic', '-c'] }, + ]), + ); + touch( + 'libs/b/build/compile_commands.json', + JSON.stringify([ + { directory: join(TMP, 'libs/b'), file: 'src/b.c', arguments: ['cc', '-Iapi', '-c'] }, + ]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.translationUnits.get('libs/a/src/a.c')?.headerSearchPaths).toEqual([ + 'libs/a/public', + ]); + expect(config.translationUnits.get('libs/b/src/b.c')?.headerSearchPaths).toEqual([ + 'libs/b/api', + ]); + const workspace = new Set([ + 'libs/a/src/a.c', + 'libs/b/src/b.c', + 'libs/a/public/a.h', + 'libs/b/api/b.h', + ]); + expect(resolveAngle(config, 'a.h', 'libs/a/src/a.c', workspace)).toBe('libs/a/public/a.h'); + expect(resolveAngle(config, 'b.h', 'libs/b/src/b.c', workspace)).toBe('libs/b/api/b.h'); + expect(resolveAngle(config, 'b.h', 'libs/a/src/a.c', workspace)).toBeNull(); + }); + + it('scopes a sub-project compile_flags.txt to its own subtree', () => { + touch('libs/a/compile_flags.txt', '-Ipublic\n-iquote\nquoted\n'); + touch('libs/a/public/a.h'); + touch('libs/a/src/a.c'); + touch('libs/b/src/b.c'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.directoryScopes.get('libs/a')).toEqual({ + headerSearchPaths: ['libs/a/public'], + userHeaderSearchPaths: ['libs/a/quoted'], + }); + expect(config.headerSearchPaths).not.toContain('libs/a/public'); + const workspace = new Set(['libs/a/src/a.c', 'libs/b/src/b.c', 'libs/a/public/a.h']); + expect(resolveAngle(config, 'a.h', 'libs/a/src/a.c', workspace)).toBe('libs/a/public/a.h'); + expect(resolveAngle(config, 'a.h', 'libs/b/src/b.c', workspace)).toBeNull(); + }); + + it('reads CMake include_directories and target_include_directories by visibility', () => { + touch( + 'CMakeLists.txt', + [ + 'project(mono C)', + 'include_directories(common)', + 'add_subdirectory(libs/net)', + 'add_subdirectory(libs/other)', + 'add_executable(app app/main.c)', + 'target_link_libraries(app PRIVATE net)', + ].join('\n'), + ); + touch( + 'libs/net/CMakeLists.txt', + [ + '# include_directories(commented-out)', + 'add_library(net src/net.c)', + 'target_include_directories(net', + ' PUBLIC $ $', + ' PRIVATE src/internal ${CMAKE_CURRENT_BINARY_DIR}/gen', + ' INTERFACE "${PROJECT_SOURCE_DIR}/shared")', + ].join('\n'), + ); + touch('common/common.h'); + touch('shared/shared.h'); + touch('libs/net/api/net.h'); + touch('libs/net/src/internal/internal.h'); + touch('libs/net/src/net.c'); + touch('app/main.c'); + touch('include/guess.h'); + touch('libs/other/src/other.c'); + touch('libs/other/CMakeLists.txt', 'add_library(other src/other.c)\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual(['common']); + expect(config.directoryScopes.get('libs/net')?.headerSearchPaths).toEqual([ + 'common', + 'libs/net/api', + 'libs/net/src/internal', + ]); + const workspace = new Set([ + 'app/main.c', + 'libs/net/src/net.c', + 'common/common.h', + 'shared/shared.h', + 'libs/net/api/net.h', + 'libs/net/src/internal/internal.h', + 'include/guess.h', + 'libs/other/src/other.c', + ]); + expect(resolveAngle(config, 'net.h', 'app/main.c', workspace)).toBe('libs/net/api/net.h'); + expect(resolveAngle(config, 'shared.h', 'app/main.c', workspace)).toBe('shared/shared.h'); + expect(resolveAngle(config, 'net.h', 'libs/other/src/other.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'shared.h', 'libs/other/src/other.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'common.h', 'libs/net/src/net.c', workspace)).toBe( + 'common/common.h', + ); + expect(resolveAngle(config, 'internal.h', 'libs/net/src/net.c', workspace)).toBe( + 'libs/net/src/internal/internal.h', + ); + expect(resolveAngle(config, 'internal.h', 'app/main.c', workspace)).toBeNull(); + // Declared roots exist, so the implicit `include/` guess is off. + expect(resolveAngle(config, 'guess.h', 'app/main.c', workspace)).toBeNull(); + }); + + it('keeps the implicit include roots off a file the compilation database lists', () => { + touch('include/guess.h'); + touch('src/main.c'); + touch( + 'compile_commands.json', + JSON.stringify([{ directory: TMP, file: 'src/main.c', arguments: ['cc', '-c'] }]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.translationUnits.get('src/main.c')?.headerSearchPaths).toEqual([]); + expect(config.headerSearchPaths).toEqual(['include']); + const workspace = new Set(['src/main.c', 'include/guess.h']); + expect(resolveAngle(config, 'guess.h', 'src/main.c', workspace)).toBeNull(); + // The header is not a database key. Implicit `include/` must not bind it. + expect(resolveAngle(config, 'guess.h', 'include/other.h', workspace)).toBeNull(); + }); + + it('uses the listed translation unit -I for a header in the same directory', () => { + touch('generated/real.h'); + touch('include/stdio.h'); + touch('src/main.c'); + touch('src/util.h'); + touch( + 'compile_commands.json', + JSON.stringify([ + { + directory: TMP, + file: 'src/main.c', + arguments: ['cc', '-Igenerated', '-c', 'src/main.c'], + }, + ]), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + const workspace = new Set(['src/main.c', 'src/util.h', 'generated/real.h', 'include/stdio.h']); + expect(resolveAngle(config, 'stdio.h', 'src/main.c', workspace)).toBeNull(); + expect(resolveAngle(config, 'real.h', 'src/main.c', workspace)).toBe('generated/real.h'); + expect(resolveAngle(config, 'stdio.h', 'src/util.h', workspace)).toBeNull(); + expect(resolveAngle(config, 'real.h', 'src/util.h', workspace)).toBe('generated/real.h'); + }); + + it('does not install implicit include roots for a root flag file with no -I', () => { + touch('include/stdio.h'); + touch('src/main.c'); + touch('compile_flags.txt', '-Wall\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual([]); + const workspace = new Set(['src/main.c', 'include/stdio.h']); + expect(resolveAngle(config, 'stdio.h', 'src/main.c', workspace)).toBeNull(); + }); + + it('ignores include_directories inside a CMake bracket comment', () => { + touch('decoy/sys.h'); + touch('src/main.c'); + touch('CMakeLists.txt', '#[[\ninclude_directories(decoy)\n]]\n'); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).not.toContain('decoy'); + const workspace = new Set(['src/main.c', 'decoy/sys.h']); + expect(resolveAngle(config, 'sys.h', 'src/main.c', workspace)).toBeNull(); + }); + + it('does not treat ${workspaceFolder}/** as an empty search root', () => { + touch('include/util.h'); + touch('src/main.c'); + touch( + '.vscode/c_cpp_properties.json', + JSON.stringify({ + configurations: [{ name: 'Linux', includePath: ['${workspaceFolder}/**'] }], + }), + ); + const config = loadCFamilyResolutionConfig(TMP, C_HEADER_EXTENSIONS); + expect(config.headerSearchPaths).toEqual(['include']); + expect(config.headerSearchPaths).not.toContain(''); + const workspace = new Set(['src/main.c', 'include/util.h']); + expect(resolveAngle(config, 'util.h', 'src/main.c', workspace)).toBe('include/util.h'); + }); +}); diff --git a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts index 3f2761d50..aa980622f 100644 --- a/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts +++ b/gitnexus/test/unit/scope-resolution/cpp/cpp-imports.test.ts @@ -108,16 +108,20 @@ describe('C++ import interpretation (interpretCppImport)', () => { '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'header.hpp'), }); - expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp' }); + // isSystem is false for quoted #include "..." (no @import.system capture). + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'header.hpp', isSystem: false }); }); - it('returns null for system headers', () => { + it('interprets system header as wildcard with isSystem:true', () => { + // The interpreter no longer returns null for system headers — it returns a + // ParsedImport with isSystem:true. The resolver (cppScopeResolver) is the + // layer that refuses to suffix-match system headers against workspace files. const result = interpretCppImport({ '@import.kind': capt('@import.kind', 'wildcard'), '@import.source': capt('@import.source', 'iostream'), '@import.system': capt('@import.system', 'true'), }); - expect(result).toBeNull(); + expect(result).toEqual({ kind: 'wildcard', targetRaw: 'iostream', isSystem: true }); }); it('interprets named import (using std::vector)', () => { diff --git a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts index bf73a6a76..fd29e7eea 100644 --- a/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts +++ b/gitnexus/test/unit/scope-resolution/external-import-conformance.test.ts @@ -342,25 +342,43 @@ const CASES: ReadonlyMap = new Map([ [ SupportedLanguages.C, { + // Workspace has a local `src/stdio.h` that shadows the system header. + // `#include ` (angle-bracket, isSystem:true) must NOT resolve to + // it. `#include "./stdio.h"` (quoted relative form, isSystem:false) from + // `src/main.c` DOES reach it via sibling lookup — the decoy-reachability + // proof. Using './stdio.h' for reachesDecoy vs 'stdio.h' for external lets + // the parsedImport factory distinguish the two arms. files: ['src/stdio.h', 'include/util.h', 'src/main.c'], fromFile: 'src/main.c', resolutionConfig: undefined, external: 'stdio.h', decoy: 'src/stdio.h', - reachesDecoy: 'util.h', + reachesDecoy: './stdio.h', + parsedImport: (targetRaw) => ({ + kind: 'wildcard', + targetRaw, + // Bare name → angle-bracket system include; relative path → quoted local. + isSystem: !targetRaw.startsWith('.'), + }), }, ], [ SupportedLanguages.CPlusPlus, { + // Same shape as C: a local `src/cstdio.h` that shadows the C++ system + // header. `#include ` (isSystem:true) must NOT resolve to it; + // `#include "./cstdio.h"` (isSystem:false) from `src/main.cpp` DOES. files: ['src/cstdio.h', 'include/util.hpp', 'src/main.cpp'], fromFile: 'src/main.cpp', resolutionConfig: undefined, - // `cstdio` with no extension would miss the decoy on spelling alone and - // post a pass that measures nothing; the header spelling is the real test. external: 'cstdio.h', decoy: 'src/cstdio.h', - reachesDecoy: 'util.hpp', + reachesDecoy: './cstdio.h', + parsedImport: (targetRaw) => ({ + kind: 'wildcard', + targetRaw, + isSystem: !targetRaw.startsWith('.'), + }), }, ], [ @@ -415,10 +433,7 @@ const CASES: ReadonlyMap = new Map([ * Fixing one means giving that language its real algorithm the way #2953 gave * TypeScript one, then deleting its line here. */ -const KNOWN_GAPS: ReadonlyMap = new Map([ - [SupportedLanguages.C, '`stdio.h` -> `src/stdio.h`'], - [SupportedLanguages.CPlusPlus, '`cstdio.h` -> `src/cstdio.h`'], -]); +const KNOWN_GAPS: ReadonlyMap = new Map([]); /** * The six that hold it, and what earns each one. diff --git a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts index 0f2a9f2e3..af18b0a00 100644 --- a/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts +++ b/gitnexus/test/unit/scope-resolution/import-target-index-reuse.contract.test.ts @@ -389,8 +389,8 @@ const FIXTURES: ReadonlyMap = new Map< [ SupportedLanguages.CPlusPlus, { - // Same accounting as C: `resolveCppImportTarget` delegates to the C - // resolver's basename index, keyed on the same Set. + // Same accounting as C. C++ keeps its own basename index, still keyed + // on this set, so the traversal count stays a function of the set. files: ['include/util.hpp', 'src/helper.hpp', 'src/main.cpp'], fromFile: 'src/main.cpp', resolutionConfig: undefined,