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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions generators/csharp/base/src/asIs/Extensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static string Stringify(this Enum value)
/// <summary>
/// Asserts that a condition is true, throwing an exception with the specified message if it is false.
/// </summary>
/// <param name="value">The object the extension method is invoked on; unused.</param>
/// <param name="condition">The condition to assert.</param>
/// <param name="message">The exception message if the assertion fails.</param>
/// <exception cref="Exception">Thrown when the condition is false.</exception>
Expand All @@ -35,6 +36,7 @@ internal static void Assert(this object value, bool condition, string message)
/// Asserts that a value is not null, throwing an exception with the specified message if it is null.
/// </summary>
/// <typeparam name="TValue">The type of the value to assert.</typeparam>
/// <param name="_unused">The object the extension method is invoked on; unused.</param>
/// <param name="value">The value to assert is not null.</param>
/// <param name="message">The exception message if the assertion fails.</param>
/// <returns>The non-null value.</returns>
Expand Down
8 changes: 4 additions & 4 deletions generators/csharp/base/src/asIs/NullableAttribute.Template.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ namespace <%= namespace%>;

/// <summary>
/// Marks a property as nullable in the OpenAPI specification.
/// When applied to Optional<T> properties, this indicates that null values should be
/// When applied to <c>Optional&lt;T&gt;</c> properties, this indicates that null values should be
/// written to JSON when the optional is defined with null.
/// </summary>
/// <remarks>
/// For regular (required) properties:
/// - Without [Nullable]: null values are invalid (omit from JSON at runtime)
/// - With [Nullable]: null values are written to JSON
///
/// For Optional<T> properties (also marked with [Optional]):
/// - Without [Nullable]: Optional<T>.Of(null) → omit from JSON (runtime edge case)
/// - With [Nullable]: Optional<T?>.Of(null) → write null to JSON
/// For <c>Optional&lt;T&gt;</c> properties (also marked with [Optional]):
/// - Without [Nullable]: <c>Optional&lt;T&gt;.Of(null)</c> → omit from JSON (runtime edge case)
/// - With [Nullable]: <c>Optional&lt;T?&gt;.Of(null)</c> → write null to JSON
/// </remarks>
[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)]
public class NullableAttribute : global::System.Attribute
Expand Down
8 changes: 4 additions & 4 deletions generators/csharp/base/src/asIs/Optional.Template.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ public override int GetHashCode()
}

/// <summary>
/// Extension methods for Optional<T> to simplify common operations.
/// Extension methods for <c>Optional&lt;T&gt;</c> to simplify common operations.
/// </summary>
public static class OptionalExtensions
{
Expand Down Expand Up @@ -345,7 +345,7 @@ Func<T, TResult> mapper
/// Adds a nullable value to a dictionary only if it is not null.
/// This is useful for regular nullable properties where null means "omit from request".
/// </summary>
/// <typeparam name="T">The type of the value (must be a reference type or Nullable<T>).</typeparam>
/// <typeparam name="T">The type of the value (must be a reference type or <c>Nullable&lt;T&gt;</c>).</typeparam>
/// <param name="value">The nullable value to add.</param>
/// <param name="dictionary">The dictionary to add to.</param>
/// <param name="key">The key to use in the dictionary.</param>
Expand Down Expand Up @@ -400,7 +400,7 @@ string key


/// <summary>
/// JSON converter factory for Optional<T> that handles undefined vs null correctly.
/// JSON converter factory for <c>Optional&lt;T&gt;</c> that handles undefined vs null correctly.
/// Uses a TypeInfoResolver to conditionally include/exclude properties based on Optional.IsDefined.
/// </summary>
public class OptionalJsonConverterFactory : JsonConverterFactory
Expand All @@ -425,7 +425,7 @@ JsonSerializerOptions options
}

/// <summary>
/// JSON converter for Optional<T> that unwraps the value during serialization.
/// JSON converter for <c>Optional&lt;T&gt;</c> that unwraps the value during serialization.
/// The actual property skipping is handled by the OptionalTypeInfoResolver.
/// </summary>
public class OptionalJsonConverter<T> : JsonConverter<Optional<T>>
Expand Down
12 changes: 6 additions & 6 deletions generators/csharp/base/src/asIs/OptionalAttribute.Template.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,16 +2,16 @@ namespace <%= namespace%>;

/// <summary>
/// Marks a property as optional in the OpenAPI specification.
/// Optional properties use the Optional<T> type and can be undefined (not present in JSON).
/// Optional properties use the <c>Optional&lt;T&gt;</c> type and can be undefined (not present in JSON).
/// </summary>
/// <remarks>
/// Properties marked with [Optional] should use the Optional<T> type:
/// - Undefined: Optional<T>.Undefined → omitted from JSON
/// - Defined: Optional<T>.Of(value) → written to JSON
/// Properties marked with [Optional] should use the <c>Optional&lt;T&gt;</c> type:
/// - Undefined: <c>Optional&lt;T&gt;.Undefined</c> → omitted from JSON
/// - Defined: <c>Optional&lt;T&gt;.Of(value)</c> → written to JSON
///
/// Combine with [Nullable] to allow null values:
/// - [Optional, Nullable] Optional<string?> → can be undefined, null, or a value
/// - [Optional] Optional<string> → can be undefined or a value (null is invalid)
/// - [Optional, Nullable] <c>Optional&lt;string?&gt;</c> → can be undefined, null, or a value
/// - [Optional] <c>Optional&lt;string&gt;</c> → can be undefined or a value (null is invalid)
/// </remarks>
[global::System.AttributeUsage(global::System.AttributeTargets.Property, AllowMultiple = false)]
public class OptionalAttribute : global::System.Attribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ public static string EncodePathSegment(string value)
#endif
}

#if NET6_0_OR_GREATER
/// <summary>
/// Builds a query string from the provided parameters.
/// </summary>
#if NET6_0_OR_GREATER
public static string Build(ReadOnlySpan<KeyValuePair<string, string>> parameters)
{
if (parameters.IsEmpty)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ public void Add(string key, Object? value)
/// <summary>
/// Converts the query parameters to a URL-encoded query string.
/// </summary>
/// <returns>A string representation of the query parameters in the format "key1=value1&key2=value2".</returns>
/// <returns>A string representation of the query parameters in the format "key1=value1&amp;key2=value2".</returns>
public override string ToString()
{
return string.Join(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ private void EnsureConnected()
/// Sends a text message instantly through the WebSocket connection.
/// </summary>
/// <param name="message">The text message to send.</param>
/// <param name="cancellationToken">Token to cancel the send operation.</param>
/// <returns>A task representing the asynchronous send operation.</returns>
/// <exception cref="Exception">Thrown when the connection is not in Connected status.</exception>
public global::System.Threading.Tasks.Task SendInstant(string message, CancellationToken cancellationToken = default)
Expand All @@ -146,6 +147,7 @@ private void EnsureConnected()
/// Sends a binary message instantly through the WebSocket connection.
/// </summary>
/// <param name="message">The binary message to send as a Memory&lt;byte&gt;.</param>
/// <param name="cancellationToken">Token to cancel the send operation.</param>
/// <returns>A task representing the asynchronous send operation.</returns>
/// <exception cref="Exception">Thrown when the connection is not in Connected status.</exception>
public global::System.Threading.Tasks.Task SendInstant(Memory<byte> message, CancellationToken cancellationToken = default)
Expand All @@ -158,6 +160,7 @@ private void EnsureConnected()
/// Sends a binary message instantly through the WebSocket connection.
/// </summary>
/// <param name="message">The binary message to send as an ArraySegment&lt;byte&gt;.</param>
/// <param name="cancellationToken">Token to cancel the send operation.</param>
/// <returns>A task representing the asynchronous send operation.</returns>
/// <exception cref="Exception">Thrown when the connection is not in Connected status.</exception>
public global::System.Threading.Tasks.Task SendInstant(ArraySegment<byte> message, CancellationToken cancellationToken = default)
Expand All @@ -170,6 +173,7 @@ private void EnsureConnected()
/// Sends a binary message instantly through the WebSocket connection.
/// </summary>
/// <param name="message">The binary message to send as a byte array.</param>
/// <param name="cancellationToken">Token to cancel the send operation.</param>
/// <returns>A task representing the asynchronous send operation.</returns>
/// <exception cref="Exception">Thrown when the connection is not in Connected status.</exception>
public global::System.Threading.Tasks.Task SendInstant(byte[] message, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public static EqualConstraint UsingOptionalComparer(this EqualConstraint constra

/// <summary>
/// EqualityAdapter for comparing IOneOf instances within NUnitEqualityComparer.
/// This enables recursive comparison of nested OneOf values within Optional<T> types.
/// This enables recursive comparison of nested OneOf values within <c>Optional&lt;T&gt;</c> types.
/// </summary>
private class OneOfEqualityAdapter : EqualityAdapter
{
Expand Down
106 changes: 102 additions & 4 deletions generators/csharp/base/src/project/CsharpProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { AsIsFiles } from "../AsIs.js";
import { GeneratorContext } from "../context/GeneratorContext.js";
import { findDotnetToolPath } from "../findDotNetToolPath.js";
import { CSharpFile } from "./CSharpFile.js";
import { TARGET_FRAMEWORKS } from "./targetFrameworks.js";

const eta = new Eta({ autoEscape: false, useWith: true, autoTrim: false });

Expand Down Expand Up @@ -822,6 +823,21 @@ function generateDeterministicGuid(name: string): string {
return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-${hash.slice(12, 16)}-${hash.slice(16, 20)}-${hash.slice(20, 32)}`.toUpperCase();
}

/**
* Joins a metadata value that may be a single string or a list into the
* delimiter-separated form MSBuild expects. Returns undefined when unset.
*/
function joinMetadataList(value: string | string[] | undefined, delimiter: string): string | undefined {
if (value == null) {
return undefined;
}
return Array.isArray(value) ? value.join(delimiter) : value;
}

function escapeXml(value: string): string {
return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}

declare namespace CsProj {
interface Args {
name: string;
Expand Down Expand Up @@ -969,6 +985,13 @@ ${this.getAdditionalItemGroups().join(`\n${indent}`)}
'<PackageReference Include="System.Text.RegularExpressions" Version="[4.3.1,)" />'
);
}
if (this.generation.settings.packageMetadata["include-source-link"]) {
pushIfNotOverridden(
result,
SOURCE_LINK_PACKAGE.name,
`<PackageReference Include="${SOURCE_LINK_PACKAGE.name}" Version="${SOURCE_LINK_PACKAGE.version}" PrivateAssets="all" />`
);
}
for (const [name, version] of Object.entries(extraDeps)) {
// PolySharp is already handled above with its required metadata.
if (name.toLowerCase() === "polysharp") {
Expand Down Expand Up @@ -1084,7 +1107,7 @@ ${this.getAdditionalItemGroups().join(`\n${indent}`)}
);
}
result.push(
`${this.generation.constants.formatting.indent}${this.generation.constants.formatting.indent}<TargetFrameworks>net462;net8.0;net9.0;netstandard2.0</TargetFrameworks>`
`${this.generation.constants.formatting.indent}${this.generation.constants.formatting.indent}<TargetFrameworks>${TARGET_FRAMEWORKS.join(";")}</TargetFrameworks>`
);
result.push(
`${this.generation.constants.formatting.indent}${this.generation.constants.formatting.indent}<ImplicitUsings>enable</ImplicitUsings>`
Expand Down Expand Up @@ -1131,15 +1154,81 @@ ${this.getAdditionalItemGroups().join(`\n${indent}`)}
);
}

if (this.githubUrl != null) {
result.push(`<PackageProjectUrl>${this.githubUrl}</PackageProjectUrl>`);
}
result.push(...this.getPackageMetadataProperties());

result.push("<PolySharpIncludeRuntimeSupportedAttributes>true</PolySharpIncludeRuntimeSupportedAttributes>");
return result;
}

/**
* NuGet package metadata and XML documentation properties, sourced from the
* `package-metadata` and `generate-documentation-file` config options and
* falling back to values derived from the IR (e.g. the GitHub URL).
*/
private getPackageMetadataProperties(): string[] {
const result: string[] = [];
const metadata = this.generation.settings.packageMetadata;

if (this.generation.settings.generateDocumentationFile) {
result.push("<GenerateDocumentationFile>true</GenerateDocumentationFile>");
// Publishing XML docs should not force every undocumented public
// member to emit CS1591.
result.push("<NoWarn>$(NoWarn);CS1591</NoWarn>");
}

if (metadata.description != null) {
result.push(`<Description>${escapeXml(metadata.description)}</Description>`);
}
const authors = joinMetadataList(metadata.authors, ",");
if (authors != null) {
result.push(`<Authors>${escapeXml(authors)}</Authors>`);
}
const tags = joinMetadataList(metadata.tags, ";");
if (tags != null) {
result.push(`<PackageTags>${escapeXml(tags)}</PackageTags>`);
}
if (metadata.copyright != null) {
result.push(`<Copyright>${escapeXml(metadata.copyright)}</Copyright>`);
}
if (metadata.icon != null) {
result.push(`<PackageIcon>${escapeXml(path.basename(metadata.icon))}</PackageIcon>`);
}

const projectUrl = metadata["project-url"] ?? this.githubUrl;
if (projectUrl != null) {
result.push(`<PackageProjectUrl>${escapeXml(projectUrl)}</PackageProjectUrl>`);
}
const repositoryUrl = metadata["repository-url"] ?? this.githubUrl;
if (repositoryUrl != null) {
result.push(`<RepositoryUrl>${escapeXml(repositoryUrl)}</RepositoryUrl>`);
result.push(`<RepositoryType>${escapeXml(metadata["repository-type"] ?? "git")}</RepositoryType>`);
}

if (metadata["include-symbols"]) {
result.push("<IncludeSymbols>true</IncludeSymbols>");
result.push("<SymbolPackageFormat>snupkg</SymbolPackageFormat>");
}
if (metadata["include-source-link"]) {
result.push("<PublishRepositoryUrl>true</PublishRepositoryUrl>");
result.push("<EmbedUntrackedSources>true</EmbedUntrackedSources>");
result.push("<Deterministic>true</Deterministic>");
}

return result;
}

private getAdditionalItemGroups(): string[] {
const result: string[] = [];
const icon = this.generation.settings.packageMetadata.icon;
if (icon != null) {
// The configured path is relative to the root of the generated output,
// matching how a custom license file is referenced below.
result.push(`
<ItemGroup>
<None Include="..\\..\\${this.relativePathToWindowsPath(RelativeFilePath.of(icon))}" Pack="true" PackagePath=""/>
</ItemGroup>
`);
}

if (this.license != null && this.license.type === "custom") {
result.push(`
Expand Down Expand Up @@ -1195,3 +1284,12 @@ const PLATFORM_HEADERS_INBOX_PACKAGE = {
name: "System.Runtime.InteropServices.RuntimeInformation",
version: "4.3.0"
} as const;

/**
* Enables SourceLink for GitHub-hosted repositories, so debuggers can step into
* the SDK sources. Only emitted when `package-metadata.include-source-link` is on.
*/
const SOURCE_LINK_PACKAGE = {
name: "Microsoft.SourceLink.GitHub",
version: "8.0.0"
} as const;
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";

import { getTargetFrameworkRequirements, TARGET_FRAMEWORKS } from "../targetFrameworks.js";

describe("getTargetFrameworkRequirements", () => {
it("renders the generated project's target frameworks", () => {
expect(getTargetFrameworkRequirements()).toEqual([
".NET 8 and above",
".NET Framework 4.6.2 and above",
".NET Standard 2.0 and above"
]);
});

it("collapses modern .NET versions into the lowest one", () => {
expect(getTargetFrameworkRequirements(["net9.0", "net10.0", "net8.0"])).toEqual([".NET 8 and above"]);
});

it("renders two-digit .NET Framework monikers", () => {
expect(getTargetFrameworkRequirements(["net48"])).toEqual([".NET Framework 4.8 and above"]);
});

it("ignores unrecognized monikers", () => {
expect(getTargetFrameworkRequirements(["net8.0-android", "nonsense"])).toEqual([]);
});

it("returns an empty list when there are no target frameworks", () => {
expect(getTargetFrameworkRequirements([])).toEqual([]);
});

it("keeps the csproj and requirements in sync", () => {
expect(TARGET_FRAMEWORKS.join(";")).toBe("net462;net8.0;net9.0;netstandard2.0");
});
});
1 change: 1 addition & 0 deletions generators/csharp/base/src/project/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { CSharpFile } from "./CSharpFile.js";
export { CsharpProject } from "./CsharpProject.js";
export * from "./PackageUtilities.js";
export { getTargetFrameworkRequirements, TARGET_FRAMEWORKS } from "./targetFrameworks.js";
50 changes: 50 additions & 0 deletions generators/csharp/base/src/project/targetFrameworks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/**
* The target frameworks every generated SDK project builds against.
*/
export const TARGET_FRAMEWORKS = ["net462", "net8.0", "net9.0", "netstandard2.0"] as const;

const NET_FRAMEWORK_PATTERN = /^net(\d)(\d)(\d)?$/;
const NET_STANDARD_PATTERN = /^netstandard(\d+)\.(\d+)$/;
const NET_CORE_PATTERN = /^net(\d+)\.(\d+)$/;

/**
* Renders the target frameworks as human-readable prerequisites for the README's
* requirements section, e.g. `net8.0` -> ".NET 8 and above".
*
* Consecutive modern .NET versions collapse into their lowest entry, since
* ".NET 8 and above" already covers `net9.0`.
*/
export function getTargetFrameworkRequirements(targetFrameworks: readonly string[] = TARGET_FRAMEWORKS): string[] {
const requirements: string[] = [];
let lowestNetCoreMajor: number | undefined;

for (const targetFramework of targetFrameworks) {
const netCore = NET_CORE_PATTERN.exec(targetFramework);
if (netCore?.[1] != null) {
const major = Number.parseInt(netCore[1], 10);
if (lowestNetCoreMajor == null || major < lowestNetCoreMajor) {
lowestNetCoreMajor = major;
}
continue;
}

const netStandard = NET_STANDARD_PATTERN.exec(targetFramework);
if (netStandard?.[1] != null && netStandard[2] != null) {
requirements.push(`.NET Standard ${netStandard[1]}.${netStandard[2]} and above`);
continue;
}

const netFramework = NET_FRAMEWORK_PATTERN.exec(targetFramework);
if (netFramework?.[1] != null && netFramework[2] != null) {
const version = [netFramework[1], netFramework[2], netFramework[3]]
.filter((part) => part != null)
.join(".");
requirements.push(`.NET Framework ${version} and above`);
}
}

if (lowestNetCoreMajor != null) {
requirements.unshift(`.NET ${lowestNetCoreMajor} and above`);
}
return requirements;
}
Loading
Loading