diff --git a/gitnexus/src/core/ingestion/languages/csharp/captures.ts b/gitnexus/src/core/ingestion/languages/csharp/captures.ts index 12d4df513..860cc8307 100644 --- a/gitnexus/src/core/ingestion/languages/csharp/captures.ts +++ b/gitnexus/src/core/ingestion/languages/csharp/captures.ts @@ -1,19 +1,24 @@ /** * `emitScopeCaptures` for C#. * - * Drives the C# scope query against tree-sitter-c-sharp and groups - * raw matches into `CaptureMatch[]` for the central extractor. + * Drives the C# scope query against tree-sitter-c-sharp and groups raw + * matches into `CaptureMatch[]` for the central extractor. Layers one + * synthesized stream on top today: * - * Unit 1 shape: pure pass-through — each tree-sitter match becomes - * one grouped `CaptureMatch`. Import decomposition (Unit 2), - * receiver-type-binding synthesis (Unit 3), and arity metadata - * synthesis (Unit 5) layer on top later. + * 1. **Decomposed using directives** — each `using_directive` is + * re-emitted with `@import.kind/source/name/alias` markers so + * `interpretCsharpImport` can recover the ParsedImport shape + * without re-parsing raw text (see `import-decomposer.ts`). + * + * Receiver-binding synthesis (`this` / `base` type anchors) and arity + * metadata synthesis (Unit 5) layer on top later. * * Pure given the input source text. No I/O, no globals consulted. */ import type { Capture, CaptureMatch } from 'gitnexus-shared'; -import { nodeToCapture } from '../../utils/ast-helpers.js'; +import { findNodeAtRange, nodeToCapture } from '../../utils/ast-helpers.js'; +import { splitUsingDirective } from './import-decomposer.js'; import { getCsharpParser, getCsharpScopeQuery } from './query.js'; import { recordCacheHit, recordCacheMiss } from './cache-stats.js'; @@ -47,6 +52,26 @@ export function emitCsharpScopeCaptures( grouped[tag] = nodeToCapture(tag, c.node); } if (Object.keys(grouped).length === 0) continue; + + // Decompose each `using_directive` so `interpretCsharpImport` sees + // the kind/source/name/alias markers it consumes. Raw query match + // only carries the @import.statement anchor. + if (grouped['@import.statement'] !== undefined) { + const stmtCapture = grouped['@import.statement']; + const stmtNode = findNodeAtRange(tree.rootNode, stmtCapture.range, 'using_directive'); + if (stmtNode !== null) { + const decomposed = splitUsingDirective(stmtNode); + if (decomposed !== null) { + out.push(decomposed); + continue; + } + } + // Defensive fallback: emit the raw match so the extractor at + // least sees an anchor, even without markers. + out.push(grouped); + continue; + } + out.push(grouped); } diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts b/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts new file mode 100644 index 000000000..fab91f89d --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/import-decomposer.ts @@ -0,0 +1,115 @@ +/** + * Decompose a C# `using_directive` into a `CaptureMatch` carrying the + * synthesized markers `@import.kind` / `@import.source` / `@import.name` + * / `@import.alias` that `interpretCsharpImport` consumes. + * + * Unlike Python's decomposer this is 1:1 — each `using` produces exactly + * one import. The split layer exists to expose the kind (namespace vs + * alias vs static) without pushing raw-text parsing into `interpret.ts`. + * + * using System; → namespace + * using System.Collections.Generic; → namespace + * using Foo = System.Bar; → alias + * using static System.Math; → static + * global using System.IO; → namespace (treated as file-scoped) + * using global::System.IO; → namespace (global:: alias stripped) + */ + +import type { Capture, CaptureMatch } from 'gitnexus-shared'; +import { nodeToCapture, syntheticCapture, type SyntaxNode } from '../../utils/ast-helpers.js'; + +type ImportKind = 'namespace' | 'alias' | 'static'; + +interface ImportSpec { + readonly kind: ImportKind; + /** Full dotted path (generics stripped): `System.Collections.Generic`. */ + readonly source: string; + /** Local binding name — last source segment for namespace/static, + * the alias for alias imports. */ + readonly name: string; + /** Present iff `kind === 'alias'`. */ + readonly alias?: string; + /** Node to anchor the synthesized captures (range-wise). */ + readonly atNode: SyntaxNode; +} + +export function splitUsingDirective(stmtNode: SyntaxNode): CaptureMatch | null { + if (stmtNode.type !== 'using_directive') return null; + const spec = parseUsingDirective(stmtNode); + if (spec === null) return null; + return buildImportMatch(stmtNode, spec); +} + +function parseUsingDirective(node: SyntaxNode): ImportSpec | null { + // tree-sitter-c-sharp's using_directive exposes named children + // corresponding to the parts of the directive but omits keyword tokens + // (`using`, `static`, `global`) from the named-child list. We inspect + // the raw source text to detect the flavor — the grammar doesn't give + // us a cleaner signal. + const raw = node.text; + + // Named child layout: + // namespace form: [pathNode] + // alias form: [aliasIdNode, pathNode] (name field = aliasId) + // static form: [pathNode] (same as namespace) + // global using: [pathNode] (same as namespace) + const aliasField = node.childForFieldName('name'); + const children = node.namedChildren; + if (children.length === 0) return null; + + // Alias form — the `name:` field is the alias identifier; the + // remaining named child is the type/namespace path. + if (aliasField !== null) { + const pathNode = children.find((c) => c !== null && c.startIndex !== aliasField.startIndex) as + | SyntaxNode + | undefined; + if (pathNode === undefined) return null; + return { + kind: 'alias', + source: stripGenericArgs(unwrapGlobalAlias(pathNode.text)), + name: aliasField.text, + alias: aliasField.text, + atNode: node, + }; + } + + const pathNode = children[0]; + if (pathNode === null) return null; + const source = stripGenericArgs(unwrapGlobalAlias(pathNode.text)); + if (source === '') return null; + const lastSegment = source.split('.').pop() ?? source; + + // `using static X.Y;` — detect by scanning the raw text before the path. + // `global using` behaves semantically as a file-scoped using for our + // purposes, so it isn't a separate kind here. + if (/^\s*(?:global\s+)?using\s+static\s/.test(raw)) { + return { kind: 'static', source, name: '*', atNode: node }; + } + + return { kind: 'namespace', source, name: lastSegment, atNode: node }; +} + +/** Strip `global::` prefix — `global::System.IO` → `System.IO`. */ +function unwrapGlobalAlias(text: string): string { + return text.replace(/^global::/, ''); +} + +/** Strip generic type arguments — `Dictionary` → `Dictionary`. */ +function stripGenericArgs(text: string): string { + const lt = text.indexOf('<'); + if (lt === -1) return text; + return text.slice(0, lt); +} + +function buildImportMatch(stmtNode: SyntaxNode, spec: ImportSpec): CaptureMatch { + const m: Record = { + '@import.statement': nodeToCapture('@import.statement', stmtNode), + '@import.kind': syntheticCapture('@import.kind', spec.atNode, spec.kind), + '@import.source': syntheticCapture('@import.source', spec.atNode, spec.source), + '@import.name': syntheticCapture('@import.name', spec.atNode, spec.name), + }; + if (spec.alias !== undefined) { + m['@import.alias'] = syntheticCapture('@import.alias', spec.atNode, spec.alias); + } + return m; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/import-target.ts b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts new file mode 100644 index 000000000..aab98759b --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/import-target.ts @@ -0,0 +1,87 @@ +/** + * Adapter from `(ParsedImport, WorkspaceIndex)` → concrete file path. + * + * Unit 2 shape: suffix-match against the repo's `.cs` files. Each + * `using System.Collections.Generic;` could legally expand to multiple + * files (every `.cs` that declares `namespace System.Collections.Generic` + * — partial classes, assembly-wide namespaces). The scope-resolver + * contract returns a single primary target, so we pick the first + * match. Cross-file partial-class aggregation runs at graph-bridge + * time (Unit 6) via `populateOwners`. + * + * The legacy csproj-based `resolveCSharpImportInternal` needs config + * objects the scope-resolver doesn't carry; the Unit 7 parity gate + * will surface cases where the suffix-match diverges from the + * namespace-based resolver and we'll adjust the contract if needed. + * + * Returning `null` lets the finalize algorithm mark the edge as + * `linkStatus: 'unresolved'`. + */ + +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; + +export interface CsharpResolveContext { + readonly fromFile: string; + readonly allFilePaths: Set; +} + +export function resolveCsharpImportTarget( + parsedImport: ParsedImport, + workspaceIndex: WorkspaceIndex, +): string | null { + const ctx = workspaceIndex as CsharpResolveContext | undefined; + if ( + ctx === undefined || + typeof (ctx as { fromFile?: unknown }).fromFile !== 'string' || + !((ctx as { allFilePaths?: unknown }).allFilePaths instanceof Set) + ) { + return null; + } + if (parsedImport.kind === 'dynamic-unresolved') return null; + if (parsedImport.targetRaw === null || parsedImport.targetRaw === '') return null; + + // Namespace path: `System.Collections.Generic` → `System/Collections/Generic`. + const pathLike = parsedImport.targetRaw.replace(/\./g, '/'); + const suffix = `/${pathLike}`; + + // Exact file match: `System/Collections/Generic.cs` (rare but legal). + // Suffix match for nested layouts: `src/lib/System/Collections/Generic.cs`. + // Directory match: first `.cs` file directly inside the namespace dir + // (e.g. `System/Collections/Generic/List.cs` matches namespace Generic). + let exactFile: string | null = null; + let suffixFile: string | null = null; + let directoryChild: string | null = null; + const dirPrefix = `${pathLike}/`; + const suffixDirPrefix = `/${dirPrefix}`; + + for (const raw of ctx.allFilePaths) { + const f = raw.replace(/\\/g, '/'); + if (!f.endsWith('.cs')) continue; + if (f === `${pathLike}.cs`) { + exactFile = raw; + break; + } + if (suffixFile === null && f.endsWith(`${suffix}.cs`)) { + suffixFile = raw; + } + if (directoryChild === null) { + // Namespace-to-directory match: pick the first `.cs` directly in + // the namespace dir (not nested deeper). Legacy resolver emits + // all of them; we take one so the scope-resolver contract stays + // single-target. + const atRoot = f.startsWith(dirPrefix); + const atNested = f.includes(suffixDirPrefix); + if (atRoot || atNested) { + const idx = atRoot ? 0 : f.indexOf(suffixDirPrefix) + 1; + const after = f.slice(idx + dirPrefix.length); + if (after.length > 0 && !after.includes('/')) { + directoryChild = raw; + } + } + } + } + + if (exactFile !== null) return exactFile; + if (suffixFile !== null) return suffixFile; + return directoryChild; +} diff --git a/gitnexus/src/core/ingestion/languages/csharp/interpret.ts b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts new file mode 100644 index 000000000..4f8182aa1 --- /dev/null +++ b/gitnexus/src/core/ingestion/languages/csharp/interpret.ts @@ -0,0 +1,114 @@ +/** + * Capture-match → semantic-shape interpreters for C#. + * + * - `interpretCsharpImport` → `ParsedImport` + * - `interpretCsharpTypeBinding` → `ParsedTypeBinding` + * + * The using-directive matches arrive pre-decomposed by + * `emitCsharpScopeCaptures` (one import per match, with synthesized + * `@import.kind/source/name/alias` markers). Type-binding matches arrive + * from the raw query captures — each `@type-binding.*` anchor carries + * `@type-binding.name` + `@type-binding.type`. + */ + +import type { CaptureMatch, ParsedImport, ParsedTypeBinding, TypeRef } from 'gitnexus-shared'; + +// ─── interpretImport ────────────────────────────────────────────────────── + +export function interpretCsharpImport(captures: CaptureMatch): ParsedImport | null { + const kindCap = captures['@import.kind']; + const sourceCap = captures['@import.source']; + const nameCap = captures['@import.name']; + const aliasCap = captures['@import.alias']; + + const kind = kindCap?.text; + if (kind === undefined || sourceCap === undefined) return null; + + switch (kind) { + case 'namespace': { + // `using System;` / `using System.Collections.Generic;` + // Bind the last segment as the local name so `Generic.Foo`-style + // qualifier references resolve. Full path is the resolution target. + return { + kind: 'namespace', + localName: nameCap?.text ?? sourceCap.text.split('.').pop() ?? sourceCap.text, + importedName: sourceCap.text, + targetRaw: sourceCap.text, + }; + } + case 'alias': { + // `using Dict = System.Collections.Generic.Dictionary;` + // The decomposer already stripped generic args from source. + if (aliasCap === undefined) return null; + const importedName = sourceCap.text.split('.').pop() ?? sourceCap.text; + return { + kind: 'alias', + localName: aliasCap.text, + importedName, + alias: aliasCap.text, + targetRaw: sourceCap.text, + }; + } + case 'static': { + // `using static System.Math;` — brings static members of Math into + // unqualified scope. Semantically closest to a wildcard: any name + // can resolve to a static member of the target type. Merge-bindings + // (Unit 4) ranks wildcards lowest so locals still shadow. + return { kind: 'wildcard', targetRaw: sourceCap.text }; + } + default: + return null; + } +} + +// ─── interpretTypeBinding ───────────────────────────────────────────────── + +export function interpretCsharpTypeBinding(captures: CaptureMatch): ParsedTypeBinding | null { + const nameCap = captures['@type-binding.name']; + const typeCap = captures['@type-binding.type']; + if (nameCap === undefined || typeCap === undefined) return null; + + // Strip nullable suffix (`User?` → `User`), single-arg generic wrapper + // (`List` → `User`), and qualifier (`System.User` → `User`) so + // receiver-typed resolution treats these identically. + const rawType = stripQualifier(stripGeneric(stripNullable(typeCap.text.trim()))); + + // Anchor captures distinguish the source of the binding. + let source: TypeRef['source'] = 'parameter-annotation'; + if (captures['@type-binding.constructor'] !== undefined) source = 'constructor-inferred'; + else if (captures['@type-binding.annotation'] !== undefined) source = 'annotation'; + else if (captures['@type-binding.alias'] !== undefined) source = 'assignment-inferred'; + else if (captures['@type-binding.return'] !== undefined) source = 'return-annotation'; + + return { boundName: nameCap.text, rawTypeName: rawType, source }; +} + +/** `User?` → `User`. */ +function stripNullable(text: string): string { + if (text.endsWith('?')) return text.slice(0, -1).trim(); + return text; +} + +/** + * Unwrap a single-arg generic collection wrapper — `List`, + * `IEnumerable`, `Task` — to its element type. Mirrors + * Python's `stripGeneric` behavior so for-loop and chain propagation + * work on the element type. + * + * Multi-arg generics (`Dictionary`, `Func`) + * are left alone — element semantics aren't unambiguous. + */ +function stripGeneric(text: string): string { + const single = text.match( + /^(?:[A-Za-z_][A-Za-z0-9_.]*\.)?(?:List|IList|IEnumerable|ICollection|IReadOnlyList|IReadOnlyCollection|HashSet|ISet|Task|ValueTask|Nullable|IAsyncEnumerable)<([^,<>]+)>$/, + ); + if (single !== null) return single[1].trim(); + return text; +} + +/** `System.Collections.User` → `User`. */ +function stripQualifier(text: string): string { + const lastDot = text.lastIndexOf('.'); + if (lastDot === -1) return text; + return text.slice(lastDot + 1); +} diff --git a/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts new file mode 100644 index 000000000..2eeccc384 --- /dev/null +++ b/gitnexus/test/unit/scope-resolution/csharp/csharp-imports.test.ts @@ -0,0 +1,168 @@ +/** + * Unit 2 coverage for the C# import interpreter + target resolver. + * + * Asserts the ParsedImport shape for every `using` flavor and checks + * the resolver adapter's single-target behavior against a small set + * of fake file paths. + */ + +import { describe, it, expect } from 'vitest'; +import { emitCsharpScopeCaptures } from '../../../../src/core/ingestion/languages/csharp/captures.js'; +import { interpretCsharpImport } from '../../../../src/core/ingestion/languages/csharp/interpret.js'; +import { resolveCsharpImportTarget } from '../../../../src/core/ingestion/languages/csharp/import-target.js'; +import type { ParsedImport, WorkspaceIndex } from 'gitnexus-shared'; + +function importsFor(src: string): ParsedImport[] { + const matches = emitCsharpScopeCaptures(src, 'test.cs'); + return matches + .filter((m) => m['@import.statement'] !== undefined) + .map((m) => interpretCsharpImport(m)) + .filter((p): p is ParsedImport => p !== null); +} + +describe('interpretCsharpImport — using flavors', () => { + it('interprets `using System;` as a namespace import', () => { + const [imp, ...rest] = importsFor('using System;\nclass A {}'); + expect(rest).toHaveLength(0); + expect(imp).toEqual({ + kind: 'namespace', + localName: 'System', + importedName: 'System', + targetRaw: 'System', + }); + }); + + it('interprets multi-segment namespace — localName is the last segment', () => { + const [imp] = importsFor('using System.Collections.Generic;\nclass A {}'); + expect(imp).toEqual({ + kind: 'namespace', + localName: 'Generic', + importedName: 'System.Collections.Generic', + targetRaw: 'System.Collections.Generic', + }); + }); + + it('interprets `using Alias = Path;` as an alias import with generics stripped', () => { + const [imp] = importsFor( + 'using Dict = System.Collections.Generic.Dictionary;\nclass A {}', + ); + expect(imp).toEqual({ + kind: 'alias', + localName: 'Dict', + importedName: 'Dictionary', + alias: 'Dict', + targetRaw: 'System.Collections.Generic.Dictionary', + }); + }); + + it('interprets `using static X.Y;` as a wildcard import', () => { + // `using static` brings static members of the target type into + // unqualified scope. Merge-bindings (Unit 4) ranks wildcards + // lowest so locals still shadow them. + const [imp] = importsFor('using static System.Math;\nclass A {}'); + expect(imp).toEqual({ kind: 'wildcard', targetRaw: 'System.Math' }); + }); + + it('strips `global::` qualifier — `using global::X.Y;` → namespace X.Y', () => { + const [imp] = importsFor('using global::System.IO;\nclass A {}'); + expect(imp).toEqual({ + kind: 'namespace', + localName: 'IO', + importedName: 'System.IO', + targetRaw: 'System.IO', + }); + }); + + it('treats `global using X;` as a file-scoped namespace import', () => { + // Plan decision: defer first-class global-using support; treat as + // same-file namespace using for this PR. Unit 7 parity gate flags + // any regression. + const [imp] = importsFor('global using System;\nclass A {}'); + expect(imp?.kind).toBe('namespace'); + expect(imp?.targetRaw).toBe('System'); + }); + + it('emits exactly one ParsedImport per using directive', () => { + const src = ` + using System; + using System.Collections.Generic; + using Dict = System.Collections.Generic.Dictionary; + using static System.Math; + `; + const imps = importsFor(src); + expect(imps).toHaveLength(4); + expect(imps.map((p) => p.kind)).toEqual(['namespace', 'namespace', 'alias', 'wildcard']); + }); +}); + +describe('resolveCsharpImportTarget — suffix match against .cs files', () => { + function ctx(fromFile: string, paths: string[]): WorkspaceIndex { + return { fromFile, allFilePaths: new Set(paths) } as unknown as WorkspaceIndex; + } + + it('resolves `MyApp.Services` to `MyApp/Services/...cs` when a direct child exists', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Services', + importedName: 'MyApp.Services', + targetRaw: 'MyApp.Services', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx('MyApp/Program.cs', [ + 'MyApp/Program.cs', + 'MyApp/Services/UserService.cs', + 'MyApp/Services/Nested/Inner.cs', + ]), + ); + expect(result).toBe('MyApp/Services/UserService.cs'); + }); + + it('resolves via suffix when namespace dir is nested under a project root', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Models', + importedName: 'MyApp.Models', + targetRaw: 'MyApp.Models', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx('src/Program.cs', ['src/Program.cs', 'src/MyApp/Models/User.cs']), + ); + expect(result).toBe('src/MyApp/Models/User.cs'); + }); + + it('returns null when no matching .cs file exists', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'Nothing', + importedName: 'Not.Here', + targetRaw: 'Not.Here', + }; + const result = resolveCsharpImportTarget( + parsed, + ctx('a.cs', ['a.cs', 'b.cs', 'some/Other/Thing.cs']), + ); + expect(result).toBe(null); + }); + + it('returns null for dynamic-unresolved imports', () => { + const parsed: ParsedImport = { kind: 'dynamic-unresolved', localName: '', targetRaw: null }; + const result = resolveCsharpImportTarget(parsed, ctx('a.cs', ['a.cs'])); + expect(result).toBe(null); + }); + + it('returns null when WorkspaceIndex has the wrong shape', () => { + const parsed: ParsedImport = { + kind: 'namespace', + localName: 'X', + importedName: 'X', + targetRaw: 'X', + }; + // Intentionally missing `allFilePaths`. + const result = resolveCsharpImportTarget(parsed, { + fromFile: 'a.cs', + } as unknown as WorkspaceIndex); + expect(result).toBe(null); + }); +});