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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: fix
packages:
- "@typespec/http-server-csharp"
---

Only emit types that belong to the service. Types declared outside the service namespace, for example in an imported library, are now emitted only when the service references them, directly or transitively.
23 changes: 9 additions & 14 deletions packages/http-server-csharp/src/diagnostics.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,5 @@
import type { Interface, Model, Program } from "@typespec/compiler";
import {
isStdNamespace,
isTemplateDeclaration,
type Namespace as TspNamespace,
} from "@typespec/compiler";
import { isTemplateDeclaration, type Namespace as TspNamespace } from "@typespec/compiler";
import { $ } from "@typespec/compiler/typekit";
import type { OperationHttpCanonicalization } from "@typespec/http-canonicalization";
import { assignAnonymousName } from "./components/models/anonymous-models.js";
Expand All @@ -13,18 +9,23 @@ import { isValidCSharpIdentifier } from "./utils/naming.js";
/**
* Reports diagnostic warnings for models, scalars, and operations.
* This pre-pass mirrors the old emitter behavior so that `tester.diagnose()` tests pass.
*
* Only types declared by the service itself are checked, since diagnostics on
* types declared elsewhere are not actionable for the spec author.
*/
export function reportEmitterDiagnostics(
program: Program,
interfaces: Interface[],
canonicalOpsMap: Map<string, OperationHttpCanonicalization[]>,
serviceNamespaces: Set<TspNamespace>,
): void {
const tk = $(program);
const visited = new Set<Model>();

// Walk all models in the service namespace(s) to check properties
for (const ns of program.getGlobalNamespaceType().namespaces.values()) {
if (isStdNamespace(ns)) continue;
// Walk the models declared by the service to check properties
const globalNs = program.getGlobalNamespaceType();
for (const ns of serviceNamespaces) {
if (ns === globalNs) continue;
walkNamespaceModels(program, ns, tk, visited);
}

Expand Down Expand Up @@ -130,12 +131,6 @@ function walkNamespaceModels(
}
}
}

// Recurse into sub-namespaces
for (const childNs of ns.namespaces.values()) {
if (isStdNamespace(childNs)) continue;
walkNamespaceModels(program, childNs, tk, visited);
}
}

function checkPropertyDiagnostics(
Expand Down
7 changes: 6 additions & 1 deletion packages/http-server-csharp/src/emitter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,12 @@ export async function $onEmit(context: EmitContext<CSharpServiceEmitterOptions>)
const projectName = options["project-name"] ?? "ServiceProject";

// Report diagnostic warnings (pre-pass before rendering)
reportEmitterDiagnostics(context.program, resolution.interfaces, resolution.canonicalOpsMap);
reportEmitterDiagnostics(
context.program,
resolution.interfaces,
resolution.canonicalOpsMap,
resolution.declarationNamespaces,
);

// Resolve OpenAPI path for SwaggerUI
let openApiPath: string | undefined = options["openapi-path"];
Expand Down
49 changes: 42 additions & 7 deletions packages/http-server-csharp/src/service-discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,59 @@ import type { Interface } from "@typespec/compiler";
import {
isStdNamespace,
isTemplateDeclaration,
listServices,
type Operation,
type Program,
type Namespace as TspNamespace,
} from "@typespec/compiler";
import type { useTsp } from "@typespec/emitter-framework";
import { getCSharpIdentifier, NameCasingType } from "./utils/naming.js";

/**
* Collects the namespaces whose declarations are emitted even when nothing references them.
*
* When the spec declares one or more services, only the service namespaces and their
* sub-namespaces qualify; types declared elsewhere are emitted only when the service
* references them.
*
* When no service is declared there is nothing to anchor the emit on, so every
* non-standard namespace is treated as part of the service.
*/
export function getDeclarationNamespaces(program: Program): Set<TspNamespace> {
const namespaces = new Set<TspNamespace>();
const globalNs = program.getGlobalNamespaceType();

function addWithChildren(ns: TspNamespace): void {
if (namespaces.has(ns)) return;
namespaces.add(ns);
for (const child of ns.namespaces.values()) {
if (isStdNamespace(child)) continue;
addWithChildren(child);
}
}

const services = listServices(program);
if (services.length === 0) {
addWithChildren(globalNs);
return namespaces;
}

// Declarations at the global namespace level are always authored by the spec itself.
namespaces.add(globalNs);
for (const service of services) {
addWithChildren(service.type);
}
return namespaces;
}

/**
* Collects all interfaces from the service namespace(s).
* Also creates synthetic interfaces for namespace-level operations
* (following the old emitter pattern: `${namespaceName}Operations`).
*/
export function getServiceInterfaces(
program: ReturnType<typeof useTsp>["$"]["program"],
serviceNamespaces: Set<TspNamespace> = getDeclarationNamespaces(program),
): Interface[] {
const interfaces: Interface[] = [];
const globalNs = program.getGlobalNamespaceType();
Expand Down Expand Up @@ -54,15 +94,10 @@ export function getServiceInterfaces(
interfaces.push(syntheticIface);
}
}

for (const childNs of ns.namespaces?.values() ?? []) {
if (isStdNamespace(childNs)) continue;
collectFromNamespace(childNs);
}
}

for (const ns of globalNs.namespaces.values()) {
if (isStdNamespace(ns)) continue;
for (const ns of serviceNamespaces) {
if (ns === globalNs) continue;
collectFromNamespace(ns);
}
return interfaces;
Expand Down
108 changes: 108 additions & 0 deletions packages/http-server-csharp/src/service-resolution.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { Tester } from "#test/tester.js";
import type { TesterInstance } from "@typespec/compiler/testing";
import { $ } from "@typespec/compiler/typekit";
import { HttpCanonicalizer } from "@typespec/http-canonicalization";
import { beforeEach, expect, it } from "vitest";
import { resolveServiceTypes, type ServiceTypeResolution } from "./service-resolution.js";

let runner: TesterInstance;

beforeEach(async () => {
runner = await Tester.createInstance();
});

async function resolve(code: string): Promise<ServiceTypeResolution> {
await runner.compile(code);
const tk = $(runner.program);
return resolveServiceTypes(runner.program, tk, new HttpCanonicalizer(tk));
}

it("excludes models declared outside the service namespace that are not referenced", async () => {
const resolution = await resolve(`
namespace Other {
model Used { name: string; }
model Unused { name: string; }
}

@service
namespace Contoso {
model Widget { id: string; used: Other.Used; }
op read(): Widget;
}
`);

expect(resolution.models.map((m) => m.name).sort()).toEqual(["Used", "Widget"]);
});

it("excludes enums and union enums declared outside the service namespace that are not referenced", async () => {
const resolution = await resolve(`
namespace Other {
enum UsedEnum { red, blue }
enum UnusedEnum { up, down }
union UsedUnion { "on", "off" }
union UnusedUnion { "left", "right" }
}

@service
namespace Contoso {
model Widget { color: Other.UsedEnum; state: Other.UsedUnion; }
op read(): Widget;
}
`);

expect(resolution.enums.map((e) => e.name)).toEqual(["UsedEnum"]);
expect(resolution.unionEnums.map((u) => u.name)).toEqual(["UsedUnion"]);
});

it("discovers types referenced transitively by an operation", async () => {
const resolution = await resolve(`
namespace Other {
model Envelope { detail: Detail; }
model Detail { kind: Kind; }
enum Kind { simple, complex }
model Untouched { name: string; }
}

@service
namespace Contoso {
op read(): Other.Envelope;
}
`);

expect(resolution.models.map((m) => m.name).sort()).toEqual(["Detail", "Envelope"]);
expect(resolution.enums.map((e) => e.name)).toEqual(["Kind"]);
});

it("does not create interfaces for operations declared outside the service namespace", async () => {
const resolution = await resolve(`
namespace Other {
interface OtherOps {
@route("/other") otherRead(): void;
}
}

@service
namespace Contoso {
interface Widgets {
@route("/widgets") read(): void;
}
}
`);

expect(resolution.interfaces.map((i) => i.name)).toEqual(["Widgets"]);
});

it("emits every namespace when no service is declared", async () => {
const resolution = await resolve(`
namespace Other {
model Standalone { name: string; }
}

namespace Contoso {
model Widget { id: string; }
op read(): Widget;
}
`);

expect(resolution.models.map((m) => m.name).sort()).toEqual(["Standalone", "Widget"]);
});
Loading
Loading