Skip to content
Merged
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
Expand Up @@ -1055,9 +1055,9 @@ private static string GetBoundary(MultipartFormDataContent content)
.Value?.Trim('"') ?? throw new global::System.Exception("Boundary not found");
}

private static <%= namespaces.core %>.MultipartFormRequest CreateMultipartFormRequest()
private static <%= namespaces.qualifiedCore %>.MultipartFormRequest CreateMultipartFormRequest()
{
return new <%= namespaces.core %>.MultipartFormRequest
return new <%= namespaces.qualifiedCore %>.MultipartFormRequest
{
BaseUrl = "https://localhost",
Method = HttpMethod.Post,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async SystemTask SendRequestAsync_ShouldNotRetry_WithMultiPartFormRequest
.WillSetStateTo("Server Error")
.RespondWith(WireMockResponse.Create().WithStatusCode(429).WithBody("Failure"));

var request = new <%= namespaces.core %>.MultipartFormRequest{
var request = new <%= namespaces.qualifiedCore %>.MultipartFormRequest{
BaseUrl = _baseUrl,
Method = HttpMethod.Post,
Path = "/test",
Expand Down Expand Up @@ -185,12 +185,12 @@ public async SystemTask SendRequestAsync_ShouldRetry_WithMultiPartFormRequest_Wi
.WhenStateIs("Success")
.RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success"));

var request = new <%= context.namespaces.core %>.MultipartFormRequest{
BaseUrl = _baseUrl,
Method = HttpMethod.Post,
Path = "/test",
};
request.AddJsonPart("object", new {});
var request = new <%= context.namespaces.qualifiedCore %>.MultipartFormRequest{
BaseUrl = _baseUrl,
Method = HttpMethod.Post,
Path = "/test",
};
request.AddJsonPart("object", new {});

var response = await _rawClient.SendRequestAsync(request);
Assert.That(response.StatusCode, Is.EqualTo(200));
Expand Down Expand Up @@ -373,8 +373,8 @@ public async SystemTask SendRequestAsync_ShouldPreserveMultipartBody_OnRetry()
.WhenStateIs("Success")
.RespondWith(WireMockResponse.Create().WithStatusCode(200).WithBody("Success"));

var request = new <%= context.namespaces.core %>.MultipartFormRequest
{
var request = new <%= context.namespaces.qualifiedCore %>.MultipartFormRequest
{
BaseUrl = _baseUrl,
Method = HttpMethod.Post,
Path = "/test",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
using System.Text.Json;
using NUnit.Framework.Constraints;
using <%= namespaces.root %>;
using <%= namespaces.root %>.Core;
using <%= namespaces.qualifiedRoot %>;
using <%= namespaces.qualifiedRoot %>.Core;

namespace NUnit.Framework;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using global::System.Text.Json;
using NUnit.Framework;
using <%= namespaces.core%>;
using <%= namespaces.qualifiedCore%>;

namespace <%= namespace%>;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
using NUnit.Framework.Constraints;
using <%= namespaces.root %>.Core;<% if (!context.generation.settings.shouldGenerateUndiscriminatedUnions) { %>
using <%= namespaces.qualifiedRoot %>.Core;<% if (!context.generation.settings.shouldGenerateUndiscriminatedUnions) { %>
using OneOf;<% } %>

namespace NUnit.Framework;
Expand Down
4 changes: 2 additions & 2 deletions generators/csharp/base/src/proto/CsharpProtobufTypeMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1040,10 +1040,10 @@ class FromProtoPropertyMapper extends WithGeneration {
}): ast.CodeBlock {
switch (primitive.v1) {
case "DATE_TIME":
return this.csharp.codeblock(`${propertyName}.ToDateTime()`);
return this.csharp.codeblock(`${propertyName}?.ToDateTime()`);
case "BASE_64":
// Proto bytes fields are ByteString; SDK exposes byte[].
return this.csharp.codeblock(`${propertyName}.ToByteArray()`);
return this.csharp.codeblock(`${propertyName}?.ToByteArray()`);
case "DATE":
case "INTEGER":
case "LONG":
Expand Down
15 changes: 11 additions & 4 deletions generators/csharp/codegen/src/ast/core/Writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,10 +130,17 @@ ${this.buffer}`;
!this.isCurrentNamespace(ns) && // filter out the current namespace.
!this.generation.registry.isNamespaceImplicit(ns) // filter out implicitly imported namespaces.
)
.map(
([ns, refs]) =>
`using ${refs.some((ref) => ref?.global) ? "global::" : ""}${refs.length > 0 ? (refs[0] as ClassReference).resolveNamespace() : ns};`
)
.map(([ns, refs]) => {
const resolvedNs = refs.length > 0 ? (refs[0] as ClassReference).resolveNamespace() : ns;
const firstSegment = resolvedNs.split(".")[0];
// Add global:: prefix when:
// 1. Any ref explicitly requires global qualification, OR
// 2. The first segment of the namespace is both a type name and a namespace root,
// which would cause CS0426 in C# (e.g., class "Candid" shadowing namespace "Candid.Net")
const needsGlobal =
refs.some((ref) => ref?.global) || this.generation.registry.hasTypeNamespaceConflict(firstSegment);
return `using ${needsGlobal ? "global::" : ""}${resolvedNs};`;
})
.join("\n");

if (result.length > 0) {
Expand Down
11 changes: 10 additions & 1 deletion generators/csharp/codegen/src/ast/types/ClassReference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ export class ClassReference extends Node implements Type {
// if the first segment in a FQN is ambiguous, then we need to globally qualify the type if it gets expanded
this.registry.isAmbiguousTypeName(this.namespaceSegments[0]) ||
this.registry.isAmbiguousNamespaceName(this.namespaceSegments[0]) ||
// if the first namespace segment is both a type name and a namespace root,
// the C# compiler will resolve it to the type instead of the namespace (CS0426)
this.registry.hasTypeNamespaceConflict(this.namespaceSegments[0]) ||
// or we always are going to be using fully qualified namespaces
writer.generation.settings.useFullyQualifiedNamespaces;

Expand Down Expand Up @@ -214,7 +217,8 @@ export class ClassReference extends Node implements Type {
const segments = typeQualification.split(".");
if (
this.registry.isAmbiguousTypeName(segments[0]) ||
this.registry.isAmbiguousNamespaceName(segments[0])
this.registry.isAmbiguousNamespaceName(segments[0]) ||
this.registry.hasTypeNamespaceConflict(segments[0])
) {
writer.write(fqName);
} else {
Expand All @@ -225,6 +229,11 @@ export class ClassReference extends Node implements Type {
// we must to fully qualify the type
// writer.addReference(this);
writer.write(fqName);
} else if (this.registry.hasTypeNamespaceConflict(this.name)) {
// If the class name itself matches a root namespace segment,
// the C# compiler resolves it as the namespace instead of the type (CS0118).
// Use the fully qualified name to disambiguate.
writer.write(fqName);
} else {
// If the class is not ambiguous and is in this specific namespace,
// we can use the short name
Expand Down
17 changes: 17 additions & 0 deletions generators/csharp/codegen/src/context/generation-info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,10 @@ export class Generation {
root: (): string => this.settings.namespace,
/** Internal Core namespace for SDK implementation details and utilities ({root}.Core). */
core: (): string => `${this.namespaces.root}.Core`,
/** Pre-qualified root namespace with global:: prefix when the root segment has a type-namespace conflict. */
qualifiedRoot: (): string => this.qualifyNamespace(this.namespaces.root),
/** Pre-qualified Core namespace with global:: prefix when the root segment has a type-namespace conflict. */
qualifiedCore: (): string => this.qualifyNamespace(this.namespaces.core),
/** Test namespace for all test-related code, canonicalized to avoid conflicts ({root}.Test). */
test: (): string => this.registry.canonicalizeNamespace(`${this.namespaces.root}.Test`),
/** Test utilities namespace for helper methods and fixtures ({root}.Test.Utils). */
Expand Down Expand Up @@ -1201,4 +1205,17 @@ export class Generation {
public get WireMock() {
return this.extern.WireMock;
}

/**
* Returns a namespace string with a `global::` prefix if the first segment
* has a type-namespace conflict (e.g., class "Candid" shadowing namespace "Candid.Net").
* Use this when writing raw namespace strings in string interpolations to avoid CS0426.
*/
public qualifyNamespace(ns: string): string {
const firstSegment = ns.split(".")[0];
if (firstSegment && this.registry.hasTypeNamespaceConflict(firstSegment)) {
return `global::${ns}`;
}
return ns;
}
}
32 changes: 32 additions & 0 deletions generators/csharp/codegen/src/context/name-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -735,6 +735,10 @@ export class NameRegistry {
this.typeNames.set("System", new Set(["System"]));
this.typeNames.set("NUnit", new Set(["NUnit"]));
this.typeNames.set("OneOf", new Set(["OneOf"]));
// Also track NUnit and OneOf as known built-in identifiers so they
// are excluded from type-namespace conflict detection (just like System)
this.knownBuiltInIdentifiers.add("NUnit");
this.knownBuiltInIdentifiers.add("OneOf");
}

/**
Expand Down Expand Up @@ -855,6 +859,34 @@ export class NameRegistry {
return name ? (this.namespaceNames.get(name)?.size ?? 0) > 1 : false;
}

/**
* Checks if a name is both a registered type name and a root-level namespace segment.
* This detects cases where a class name shadows a namespace root, causing CS0426 errors.
*
* For example, if there's a class `Candid` in namespace `Candid.Net`, then any reference
* to `Candid.Net.Something` from within the `Candid.Net` namespace tree will fail because
* the C# compiler resolves `Candid` to the class instead of the namespace.
*
* @param name - The name to check (optional)
* @returns `true` if the name is both a type name and a root namespace segment, `false` otherwise
*/
public hasTypeNamespaceConflict(name?: string): boolean {
if (!name) {
return false;
}
// Exclude known built-in identifiers (System, NUnit, OneOf, etc.) since these
// are framework names that don't create shadowing conflicts in user code.
// The conflict we're detecting is when a USER-DEFINED type name (like a client
// class "Candid") matches a root namespace segment (like "Candid" in "Candid.Net").
if (this.knownBuiltInIdentifiers.has(name)) {
return false;
}
// Check if this name is a tracked type name AND a root-level namespace segment
// (i.e., it appears as the first segment of some namespace, indicated by having
// an empty string "" as a parent in the namespaceNames registry)
return this.typeNames.has(name) && (this.namespaceNames.get(name)?.has("") ?? false);
}

/**
* Generates a fully qualified name string from a class reference identity.
* For nested types, includes the enclosing type in the qualified name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,9 @@ export class GrpcEndpointGenerator extends AbstractEndpointGenerator {
grpcClientInfo: GrpcClientInfo;
}): ast.CodeBlock {
const mapToProtoRequest =
request != null ? this.getToProtoMethodInvocation({ request }) : this.csharp.codeblock("null");
request != null
? this.getToProtoMethodInvocation({ request })
: this.csharp.codeblock("new Google.Protobuf.WellKnownTypes.Empty()");
return this.csharp.codeblock((writer) => {
writer.write("var call = ");
writer.writeNode(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1830,7 +1830,7 @@ export class HttpEndpointGenerator extends AbstractEndpointGenerator {
return {
code: this.csharp.codeblock((writer) => {
writer.write(
`var ${this.names.variables.headers} = await new ${this.namespaces.core}.HeadersBuilder.Builder()`
`var ${this.names.variables.headers} = await new ${this.namespaces.qualifiedCore}.HeadersBuilder.Builder()`
);
writer.indent();
writer.writeLine(".Add(_client.Options.Headers)");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export class BytesOnlyEndpointRequest extends EndpointRequest {
code: this.csharp.codeblock((writer) => {
// Start with HeadersBuilder.Builder instance
writer.write(
`var ${this.names.variables.headers} = await new ${this.namespaces.core}.HeadersBuilder.Builder()`
`var ${this.names.variables.headers} = await new ${this.namespaces.qualifiedCore}.HeadersBuilder.Builder()`
);
writer.indent();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class ReferencedEndpointRequest extends EndpointRequest {
code: this.csharp.codeblock((writer) => {
// Start with HeadersBuilder.Builder instance
writer.write(
`var ${this.names.variables.headers} = await new ${this.namespaces.core}.HeadersBuilder.Builder()`
`var ${this.names.variables.headers} = await new ${this.namespaces.qualifiedCore}.HeadersBuilder.Builder()`
);
writer.indent();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ export class WrappedEndpointRequest extends EndpointRequest {
return {
code: this.csharp.codeblock((writer) => {
writer.write(
`var ${queryStringVar} = new ${this.namespaces.core}.QueryStringBuilder.Builder(capacity: ${this.endpoint.queryParameters.length})`
`var ${queryStringVar} = new ${this.namespaces.qualifiedCore}.QueryStringBuilder.Builder(capacity: ${this.endpoint.queryParameters.length})`
);
writer.indent();
for (const query of this.endpoint.queryParameters) {
Expand Down Expand Up @@ -185,7 +185,7 @@ export class WrappedEndpointRequest extends EndpointRequest {
code: this.csharp.codeblock((writer) => {
// Start with HeadersBuilder.Builder instance
writer.write(
`var ${this.names.variables.headers} = await new ${this.namespaces.core}.HeadersBuilder.Builder()`
`var ${this.names.variables.headers} = await new ${this.namespaces.qualifiedCore}.HeadersBuilder.Builder()`
);
writer.indent();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -486,7 +486,7 @@ export class WebSocketClientGenerator extends WithGeneration {

if (hasQueryParameters) {
writer.write(
`\n{\n Query = new ${this.namespaces.core}.QueryStringBuilder.Builder(capacity: ${this.websocketChannel.queryParameters.length})`
`\n{\n Query = new ${this.namespaces.qualifiedCore}.QueryStringBuilder.Builder(capacity: ${this.websocketChannel.queryParameters.length})`
);
for (const queryParameter of this.websocketChannel.queryParameters) {
const isComplexType = this.isComplexType(queryParameter.valueType);
Expand Down
23 changes: 23 additions & 0 deletions generators/csharp/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,27 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
- version: 2.32.0
changelogEntry:
- summary: |
Add support for the `google.protobuf.Empty` well-known type for gRPC endpoints.
type: feat
- summary: |
Add more permissive handling for optional `google.protobuf.Timestamp` response properties.
type: fix
createdAt: "2026-03-17"
irVersion: 65

- version: 2.31.1
changelogEntry:
- summary: |
Fix CS0426 compilation error when the client class name matches a namespace
root segment (e.g., class `Candid` in namespace `Candid.Net`). The C# compiler
previously resolved `Candid.Net` as looking for a `Net` member on the `Candid`
type instead of the `Candid.Net` namespace. The generator now uses `global::`
prefixes in both inline references and `using` directives to disambiguate.
type: fix
createdAt: "2026-03-16"
irVersion: 65

- version: 2.31.0
changelogEntry:
- summary: |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,7 @@ import subprocess
import pytest

_STARTED: bool = False
_EXTERNAL: bool = False # True when using an external WireMock instance (skip container lifecycle)
_WIREMOCK_URL: str = "http://localhost:8080" # Default, will be updated after container starts
_PROJECT_NAME: str = "${projectName}"

Expand All @@ -410,10 +411,19 @@ def _get_wiremock_port() -> str:

def _start_wiremock() -> None:
"""Starts the WireMock container using docker-compose."""
global _STARTED, _WIREMOCK_URL
global _STARTED, _EXTERNAL, _WIREMOCK_URL
if _STARTED:
return

# If WIREMOCK_URL is already set (e.g., by CI/CD pipeline), skip container management
existing_url = os.environ.get("WIREMOCK_URL")
if existing_url:
_WIREMOCK_URL = existing_url
_EXTERNAL = True
_STARTED = True
print(f"\\nUsing external WireMock at {_WIREMOCK_URL} (container management skipped)")
return

print(f"\\nStarting WireMock container (project: {_PROJECT_NAME})...")
try:
subprocess.run(
Expand All @@ -434,6 +444,10 @@ def _start_wiremock() -> None:

def _stop_wiremock() -> None:
"""Stops and removes the WireMock container."""
if _EXTERNAL:
# Container is managed externally; nothing to tear down.
return

print("\\nStopping WireMock container...")
subprocess.run(
["docker", "compose", "-f", _COMPOSE_FILE, "-p", _PROJECT_NAME, "down", "-v"],
Expand Down
11 changes: 11 additions & 0 deletions generators/python/sdk/versions.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# yaml-language-server: $schema=../../../fern-versions-yml.schema.json
# For unreleased changes, use unreleased.yml
- version: 5.0.3
changelogEntry:
- summary: |
Skip Docker container management in generated `tests/conftest.py` when the
`WIREMOCK_URL` environment variable is already set. This allows wire tests to
run in CI/CD pipelines that provide an external WireMock sidecar container
without requiring Docker-in-Docker support.
type: fix
createdAt: "2026-03-17"
irVersion: 65

- version: 5.0.2
changelogEntry:
- summary: |
Expand Down
Loading
Loading