mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-21 00:21:30 +00:00
feat(csharp-scope): Unit 2 — import interpret + target resolver
Adds the three files Unit 2 of the C# scope-resolution plan calls for:
- `import-decomposer.ts` — inspects each `using_directive` node and
synthesizes `@import.kind/source/name/alias` markers. Kinds:
`namespace` — `using X;` / `using X.Y.Z;`
`alias` — `using Alias = X.Y.Z;` (generics stripped)
`static` — `using static X.Y;`
`global using` maps to namespace (plan's deferred decision); the
`global::` qualifier is stripped before emitting.
- `interpret.ts` — reads the markers and builds `ParsedImport`. Static
using maps to `kind: 'wildcard'` since it brings members into
unqualified scope; Unit 4's merge-bindings tiers wildcards lowest.
Also provides `interpretCsharpTypeBinding` with nullable/single-arg
generic/qualifier stripping so receiver-typed resolution sees the
concrete class name.
- `import-target.ts` — suffix-match adapter returning a single primary
file. Cross-file partial-class aggregation runs later at graph-bridge
time (Unit 6). The csproj-based `resolveCSharpImportInternal` stays
on the legacy path until Unit 7's parity gate surfaces a gap.
- `captures.ts` routes `@import.statement` matches through the
decomposer so the interpreter sees the markers it needs.
Tests cover every using flavor + resolution edge cases. 38/38 scope-
resolution C# unit tests pass; tsc clean.
This commit is contained in:
parent
a58a478c06
commit
a3a2c65cc7
5 changed files with 516 additions and 7 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, int>` → `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<string, Capture> = {
|
||||
'@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;
|
||||
}
|
||||
|
|
@ -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<string>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
114
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal file
114
gitnexus/src/core/ingestion/languages/csharp/interpret.ts
Normal file
|
|
@ -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<string, int>;`
|
||||
// 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>` → `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<User>`,
|
||||
* `IEnumerable<User>`, `Task<User>` — to its element type. Mirrors
|
||||
* Python's `stripGeneric` behavior so for-loop and chain propagation
|
||||
* work on the element type.
|
||||
*
|
||||
* Multi-arg generics (`Dictionary<string, User>`, `Func<int, User>`)
|
||||
* 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);
|
||||
}
|
||||
|
|
@ -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<string, int>;\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<string, int>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue