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
5 changes: 5 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ jobs:
- name: Restore dependencies
run: dotnet restore ObsWebSocket.sln

- name: Check formatting
run: |
dotnet tool restore
dotnet csharpier check .

- name: Build Solution
run: dotnet build ObsWebSocket.sln --configuration Release --no-restore

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ public sealed class GenerateObsWebSocketSourcesTask : Microsoft.Build.Utilities.

public override bool Execute()
{
int exitCode = ProtocolCodegenRunner.GenerateAsync(
int exitCode = ProtocolCodegenRunner
.GenerateAsync(
protocolPath: ProtocolPath,
outputDirectory: OutputDirectory,
downloadIfMissing: DownloadIfMissing,
Expand Down
14 changes: 14 additions & 0 deletions ObsWebSocket.Codegen.Tasks/Generation/Diagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,20 @@ internal static class Diagnostics
isEnabledByDefault: true
);

/// <summary>
/// Reported when the protocol defines a <c>Number</c> field the numeric table does not
/// classify. The field still maps to <c>double</c>, which is the safe fallback, but a whole
/// number field left unclassified reaches callers as a floating point value.
/// </summary>
public static readonly DiagnosticDescriptor UnclassifiedNumberField = new(
id: "OBSWSGEN012",
title: "Unclassified Number field",
messageFormat: "Number field '{0}' in '{1}' is not listed in NumericFieldTable. Mapping to 'double'. Add it to the table if it holds whole numbers.",
category: Category,
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true
);

/// <summary>
/// Informational diagnostic reported when an optional field that is a value type (struct) is generated as a nullable value type.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -729,12 +729,12 @@ out bool isRootOfNested
|| f.ValueName.EndsWith("." + objectNode.Name)
);
}

/// <summary>
/// Reports whether a field's description says it can be null, which the protocol states in
/// prose for fields it does not otherwise mark optional.
/// </summary>
private static bool DescriptionAllowsNull(string? description) =>
!string.IsNullOrEmpty(description)
&& description.IndexOf("null", StringComparison.OrdinalIgnoreCase) >= 0;

}
167 changes: 101 additions & 66 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.EventStreams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,9 @@ internal static partial class Emitter
{
/// <summary>
/// Generates one stream accessor per protocol event, each wrapping the corresponding
/// classic event so it can be consumed with <c>await foreach</c>.
/// classic event so it can be consumed with <c>await foreach</c>. The accessors go onto the
/// same category group as that category's requests, because the protocol documents requests
/// and events under one set of category headings.
/// </summary>
/// <param name="context">The source production context.</param>
/// <param name="protocol">The parsed protocol definition.</param>
Expand All @@ -26,6 +28,12 @@ ProtocolDefinition protocol
return;
}

HashSet<string> requestCategories = new(StringComparer.OrdinalIgnoreCase);
foreach (RequestDefinition request in protocol.Requests ?? [])
{
_ = requestCategories.Add(request.Category ?? "general");
}

StringBuilder builder = BuildSourceHeader("// Helper: per-event IAsyncEnumerable streams");
builder.AppendLine("using System;");
builder.AppendLine("using System.Collections.Generic;");
Expand All @@ -36,86 +44,113 @@ ProtocolDefinition protocol
builder.AppendLine();
builder.AppendLine($"namespace {ExtensionsNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Observes OBS events as async sequences. Each accessor subscribes for the lifetime"
);
builder.AppendLine(
"/// of the enumeration and unsubscribes when it ends, so the caller never manages handlers."
);
builder.AppendLine("/// </summary>");
builder.AppendLine("public static class ObsWebSocketClientEventStreams");
builder.AppendLine("{");

foreach (OBSEvent? eventDef in protocol.Events)
foreach (
IGrouping<string, OBSEvent> group in protocol
.Events.GroupBy(e => e.Category ?? "general", StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g.Key, StringComparer.Ordinal)
)
{
try
{
string eventName = SanitizeIdentifier(eventDef.EventType);
if (string.IsNullOrEmpty(eventName))
{
continue;
}
string groupName = ToGroupName(group.Key);

string eventArgsTypeName = $"{GeneratedEventArgsNamespace}.{eventName}EventArgs";
builder.AppendLine("/// <summary>");
builder.AppendLine(
$"/// Events in the <c>{System.Security.SecurityElement.Escape(group.Key)}</c> category, as async sequences."
);
builder.AppendLine(
"/// Each accessor subscribes for the lifetime of the enumeration and unsubscribes"
);
builder.AppendLine("/// when it ends, so the caller never manages handlers.");
builder.AppendLine("/// </summary>");

builder.AppendLine(" /// <summary>");
// A category with events but no requests has no group declared elsewhere, so this
// part has to carry the primary constructor.
if (requestCategories.Contains(group.Key))
{
builder.AppendLine($"public readonly partial struct {groupName}Group");
}
else
{
builder.AppendLine(
$" /// Streams <c>{eventName}</c> events as they arrive."
"/// <param name=\"client\">The client these events are observed on.</param>"
);
if (!string.IsNullOrWhiteSpace(eventDef.Description))
builder.AppendLine(
$"public readonly partial struct {groupName}Group(ObsWebSocketClient client)"
);
}

builder.AppendLine("{");

foreach (OBSEvent eventDef in group)
{
try
{
string eventName = SanitizeIdentifier(eventDef.EventType);
if (string.IsNullOrEmpty(eventName))
{
continue;
}

string eventArgsTypeName =
$"{GeneratedEventArgsNamespace}.{eventName}EventArgs";

builder.AppendLine(" /// <summary>");
builder.AppendLine(
$" /// <para>{FlattenDescription(eventDef.Description)}</para>"
$" /// Streams <c>{eventName}</c> events as they arrive."
);
}
if (!string.IsNullOrWhiteSpace(eventDef.Description))
{
builder.AppendLine(
$" /// <para>{FlattenDescription(eventDef.Description)}</para>"
);
}

builder.AppendLine(" /// </summary>");
builder.AppendLine(" /// <param name=\"client\">The ObsWebSocketClient instance.</param>");
builder.AppendLine(
" /// <param name=\"capacity\">Events buffered before the oldest is dropped.</param>"
);
builder.AppendLine(
" /// <param name=\"cancellationToken\">Ends the enumeration and unsubscribes.</param>"
);
if (!string.IsNullOrWhiteSpace(eventDef.EventSubscription))
{
builder.AppendLine(" /// </summary>");
builder.AppendLine(
$" /// <remarks>Requires the <c>{System.Security.SecurityElement.Escape(eventDef.EventSubscription)}</c> subscription.</remarks>"
" /// <param name=\"capacity\">Events buffered before the oldest is dropped.</param>"
);
}
builder.AppendLine(
" /// <param name=\"cancellationToken\">Ends the enumeration and unsubscribes.</param>"
);
if (!string.IsNullOrWhiteSpace(eventDef.EventSubscription))
{
builder.AppendLine(
$" /// <remarks>Requires the <c>{System.Security.SecurityElement.Escape(eventDef.EventSubscription)}</c> subscription.</remarks>"
);
}

builder.AppendLine(
$" public static IAsyncEnumerable<{eventArgsTypeName}> {eventName}Stream("
);
builder.AppendLine(" this ObsWebSocketClient client,");
builder.AppendLine(" int capacity = EventStream.DefaultCapacity,");
builder.AppendLine(" CancellationToken cancellationToken = default)");
builder.AppendLine(" {");
builder.AppendLine(" ArgumentNullException.ThrowIfNull(client);");
builder.AppendLine($" return EventStream.Create<{eventArgsTypeName}>(");
builder.AppendLine($" handler => client.{eventName} += handler,");
builder.AppendLine($" handler => client.{eventName} -= handler,");
builder.AppendLine(" capacity,");
builder.AppendLine(" cancellationToken);");
builder.AppendLine(" }");
builder.AppendLine();
}
catch (Exception ex)
{
context.ReportDiagnostic(
Diagnostic.Create(
Diagnostics.IdentifierGenerationError,
Location.None,
eventDef.EventType,
$"Generating event stream for {eventDef.EventType}",
ex.Message
)
);
builder.AppendLine(
$" public IAsyncEnumerable<{eventArgsTypeName}> {eventName}Stream("
);
builder.AppendLine(" int capacity = EventStream.DefaultCapacity,");
builder.AppendLine(" CancellationToken cancellationToken = default)");
builder.AppendLine(" {");
builder.AppendLine(" ObsWebSocketClient source = client;");
builder.AppendLine($" return EventStream.Create<{eventArgsTypeName}>(");
builder.AppendLine($" handler => source.{eventName} += handler,");
builder.AppendLine($" handler => source.{eventName} -= handler,");
builder.AppendLine(" capacity,");
builder.AppendLine(" cancellationToken);");
builder.AppendLine(" }");
builder.AppendLine();
}
catch (Exception ex)
{
context.ReportDiagnostic(
Diagnostic.Create(
Diagnostics.IdentifierGenerationError,
Location.None,
eventDef.EventType,
$"Generating event stream for {eventDef.EventType}",
ex.Message
)
);
}
}
}

builder.AppendLine("}");
builder.AppendLine("}");
builder.AppendLine();
}

context.AddSource(
"ObsWebSocketClient.EventStreams.g.cs",
Expand Down
21 changes: 19 additions & 2 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -238,16 +238,33 @@ string parentDtoName
// Map specifically named 'Object' field to Stub record
// Use the fully qualified name to avoid potential namespace conflicts
return ($"{GeneratedCommonNamespace}.SceneItemTransformStub?", false);
// Add other specific 'Object' mappings here if needed in the future
// Add other specific 'Object' mappings here if needed in the future
}
// If not handled above, it falls through to the general 'Object'/'Any' handling below
}

// --- Basic Type Mapping ---
string? numberType = null;
if (obsType == "Number")
{
numberType = NumericFieldTable.MapNumber(fieldName, out bool classified);
if (!classified)
{
context.ReportDiagnostic(
Diagnostic.Create(
Diagnostics.UnclassifiedNumberField,
Location.None,
fieldName,
parentDtoName
)
);
}
}

string? mappedType = obsType switch
{
"String" => "string",
"Number" => "double",
"Number" => numberType,
"Boolean" => "bool",
"Uuid" => "string",
"Object" or "Any" => "System.Text.Json.JsonElement?", // Fallback for unhandled Object/Any
Expand Down
2 changes: 1 addition & 1 deletion ObsWebSocket.Codegen.Tasks/Generation/Emitter.Hierarchy.cs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ SourceProductionContext context
currentNode = newNode;
}
}
NextFieldPass1:
NextFieldPass1:
;
}

Expand Down
28 changes: 21 additions & 7 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.JsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@ ProtocolDefinition protocol
{
try
{
StringBuilder builder = BuildSourceHeader("// Serialization Context: ObsWebSocketJsonContext");
StringBuilder builder = BuildSourceHeader(
"// Serialization Context: ObsWebSocketJsonContext"
);

_ = builder.AppendLine("using System.Collections.Generic;");
_ = builder.AppendLine("using System.Text.Json;");
Expand All @@ -34,16 +36,26 @@ ProtocolDefinition protocol
_ = builder.AppendLine();
_ = builder.AppendLine("[JsonSourceGenerationOptions(");
_ = builder.AppendLine(" PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,");
_ = builder.AppendLine(" DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]");
_ = builder.AppendLine(
" DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]"
);

// Fixed protocol wrapper and payload types.
_ = builder.AppendLine("[JsonSerializable(typeof(OutgoingMessage<RequestPayload>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(OutgoingMessage<IdentifyPayload>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(OutgoingMessage<ReidentifyPayload>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(OutgoingMessage<RequestBatchPayload>))]");
_ = builder.AppendLine(
"[JsonSerializable(typeof(OutgoingMessage<ReidentifyPayload>))]"
);
_ = builder.AppendLine(
"[JsonSerializable(typeof(OutgoingMessage<RequestBatchPayload>))]"
);
_ = builder.AppendLine("[JsonSerializable(typeof(IncomingMessage<JsonElement>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(RequestResponsePayload<JsonElement>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(RequestBatchResponsePayload<JsonElement>))]");
_ = builder.AppendLine(
"[JsonSerializable(typeof(RequestResponsePayload<JsonElement>))]"
);
_ = builder.AppendLine(
"[JsonSerializable(typeof(RequestBatchResponsePayload<JsonElement>))]"
);
_ = builder.AppendLine("[JsonSerializable(typeof(EventPayloadBase<JsonElement>))]");
_ = builder.AppendLine("[JsonSerializable(typeof(HelloPayload))]");
_ = builder.AppendLine("[JsonSerializable(typeof(IdentifiedPayload))]");
Expand Down Expand Up @@ -111,7 +123,9 @@ ProtocolDefinition protocol
);
}

_ = builder.AppendLine("internal sealed partial class ObsWebSocketJsonContext : JsonSerializerContext");
_ = builder.AppendLine(
"internal sealed partial class ObsWebSocketJsonContext : JsonSerializerContext"
);
_ = builder.AppendLine("{");
_ = builder.AppendLine("}");

Expand Down
Loading
Loading