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
71 changes: 58 additions & 13 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.DtoGeneration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -489,23 +489,43 @@ ProtocolDefinition protocol
}
else // Response, Event, Nested
{
isConsideredRequired = !typeIsInherentlyNullable && !csharpType.EndsWith("?");
// The protocol never marks a response or event field optional: all 152
// response fields and all 149 event fields carry a null valueOptional. The
// only place it records that a field can be absent is the prose, which is
// therefore the signal, rather than whether C# happens to make the type
// nullable. Without this a string OBS always sends still arrives nullable and
// every read needs a "!".
bool proseAllowsNull = DescriptionAllowsNull(
associatedFieldDef.ValueDescription
);

// Some fields are only ever null in a particular state, which the protocol
// records in the description rather than in valueOptional. Deserializing
// those into a non-nullable value type fails outright when it happens.
if (
isConsideredRequired
&& isValueType
&& DescriptionAllowsNull(associatedFieldDef.ValueDescription)
)
if (proseAllowsNull)
{
isConsideredRequired = false;
}

if (csharpType.StartsWith("List<") || csharpType.StartsWith("Dictionary<"))
else if (isValueType)
{
isConsideredRequired = false;
isConsideredRequired = !csharpType.EndsWith("?");
}
else
{
// Strings and arrays are always sent. A dictionary or a JsonElement is a
// settings bag that genuinely may not be there, so those stay nullable.
// The array and stub mappers bake the "?" into the type they return, so
// those have to have it stripped here rather than merely left off the
// suffix. A stub is a concrete record for an object OBS always sends,
// unlike a JsonElement settings bag.
bool isList = csharpType.Contains("List<");
bool isStub =
csharpType.EndsWith("Stub?", StringComparison.Ordinal)
|| csharpType.EndsWith("Stub", StringComparison.Ordinal);
isConsideredRequired = csharpType == "string" || isList || isStub;
if (
(isList || isStub) && csharpType.EndsWith("?", StringComparison.Ordinal)
)
{
csharpType = csharpType.Substring(0, csharpType.Length - 1);
}
}
}
propertyNullableSuffix =
Expand All @@ -525,6 +545,19 @@ ProtocolDefinition protocol
continue;
}

// A field mapped onto a protocol enum needs the wire value on both transports, not the
// member name and not the ordinal. It is never nullable: OBS always sends a value, and
// one it does not recognise maps onto the enum's zero member rather than onto null, so
// a caller never has to null check a state before switching on it.
string? propertyStringEnum =
associatedFieldDef?.ValueType == "String"
? StringEnumFieldTable.MapStringEnum(originalName)
: null;
if (propertyStringEnum is not null)
{
propertyNullableSuffix = string.Empty;
}

PropertyGenInfo propInfo = new(
propertyName,
ToCamelCase(propertyName),
Expand Down Expand Up @@ -561,6 +594,16 @@ ProtocolDefinition protocol

mainBuilder.AppendLine($" [JsonPropertyName(\"{originalName}\")]");
mainBuilder.AppendLine($" [Key(\"{originalName}\")]");

if (propertyStringEnum is not null)
{
mainBuilder.AppendLine(
$" [JsonConverter(typeof({GeneratedEnumsNamespace}.{propertyStringEnum}JsonConverter))]"
);
mainBuilder.AppendLine(
$" [MessagePackFormatter(typeof({GeneratedEnumsNamespace}.{propertyStringEnum}MessagePackFormatter))]"
);
}
mainBuilder.Append(" public ");
if (isConsideredRequired)
{
Expand Down Expand Up @@ -648,7 +691,9 @@ out bool isRootOfNested
// Append optional parameters WITH default null value
constructorParams.AddRange(
optionalParamsList.Select(p =>
$"{p.CSharpType}{p.NullableSuffix} {p.ParamName} = null"
// A non-nullable optional parameter cannot default to null. That happens for
// the enum-typed fields, whose zero member is the "unknown" one anyway.
$"{p.CSharpType}{p.NullableSuffix} {p.ParamName} = {(p.NullableSuffix.Length == 0 ? "default" : "null")}"
)
);

Expand Down
47 changes: 39 additions & 8 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.EventStreams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,48 @@ IGrouping<string, OBSEvent> group in protocol
string eventArgsTypeName =
$"{GeneratedEventArgsNamespace}.{eventName}EventArgs";

string subscriptionRemark = string.IsNullOrWhiteSpace(
eventDef.EventSubscription
)
? string.Empty
: $" /// <remarks>Requires the <c>{System.Security.SecurityElement.Escape(eventDef.EventSubscription)}</c> subscription.</remarks>";

string descriptionLine = string.IsNullOrWhiteSpace(eventDef.Description)
? string.Empty
: $" /// <para>{FlattenDescription(eventDef.Description)}</para>";

// The classic handler, on the group rather than only on the client. Explicit
// accessors are what make this work: the group is a struct the property hands
// out fresh, so a field-like event would add to a temporary and lose it.
builder.AppendLine(" /// <summary>");
builder.AppendLine($" /// Occurs when OBS raises <c>{eventName}</c>.");
if (descriptionLine.Length > 0)
{
builder.AppendLine(descriptionLine);
}

builder.AppendLine(" /// </summary>");
if (subscriptionRemark.Length > 0)
{
builder.AppendLine(subscriptionRemark);
}

builder.AppendLine(
$" public event EventHandler<{eventArgsTypeName}>? {eventName}"
);
builder.AppendLine(" {");
builder.AppendLine($" add => client.{eventName} += value;");
builder.AppendLine($" remove => client.{eventName} -= value;");
builder.AppendLine(" }");
builder.AppendLine();

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

builder.AppendLine(" /// </summary>");
Expand All @@ -112,11 +145,9 @@ IGrouping<string, OBSEvent> group in protocol
builder.AppendLine(
" /// <param name=\"cancellationToken\">Ends the enumeration and unsubscribes.</param>"
);
if (!string.IsNullOrWhiteSpace(eventDef.EventSubscription))
if (subscriptionRemark.Length > 0)
{
builder.AppendLine(
$" /// <remarks>Requires the <c>{System.Security.SecurityElement.Escape(eventDef.EventSubscription)}</c> subscription.</remarks>"
);
builder.AppendLine(subscriptionRemark);
}

builder.AppendLine(
Expand Down
7 changes: 6 additions & 1 deletion ObsWebSocket.Codegen.Tasks/Generation/Emitter.Helpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,14 @@ string parentDtoName
}
}

// A String field that carries a protocol enum is generated as that enum, with converters
// that map to and from the wire value on both transports.
string? stringEnum =
obsType == "String" ? StringEnumFieldTable.MapStringEnum(fieldName) : null;

string? mappedType = obsType switch
{
"String" => "string",
"String" => stringEnum is null ? "string" : $"{GeneratedEnumsNamespace}.{stringEnum}",
"Number" => numberType,
"Boolean" => "bool",
"Uuid" => "string",
Expand Down
102 changes: 102 additions & 0 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadSchema.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
// ObsWebSocket.Codegen.Tasks/Generation/Emitter.PayloadSchema.cs
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace ObsWebSocket.Codegen.Tasks.Generation;

/// <summary>
/// Emits the wire keys each response record expects, so a payload can be checked against the type
/// it is about to be read as.
/// </summary>
internal static partial class Emitter
{
/// <summary>
/// Generates a lookup from response record name to the field names OBS sends for it.
/// </summary>
/// <remarks>
/// Reading a payload as the wrong record is silent on MessagePack, which maps by key name and
/// leaves everything unmatched at its default, and silent on JSON too for the records that
/// happen to have no required member. Response records almost never share field names, so
/// checking that a payload carries at least one key the target record knows catches the
/// mistake on both transports.
/// </remarks>
/// <param name="context">The source production context.</param>
/// <param name="protocol">The parsed protocol definition.</param>
public static void GeneratePayloadSchema(
SourceProductionContext context,
ProtocolDefinition protocol
)
{
if (protocol.Requests is null || protocol.Requests.Count == 0)
{
return;
}

StringBuilder builder = BuildSourceHeader("// Wire keys per response record");
builder.AppendLine("using System;");
builder.AppendLine("using System.Collections.Generic;");
builder.AppendLine();
builder.AppendLine("namespace ObsWebSocket.Core.Serialization;");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// The field names OBS sends for each response record, used to reject a payload being"
);
builder.AppendLine("/// read as a record it did not come from.");
builder.AppendLine("/// </summary>");
builder.AppendLine("internal static class ObsWebSocketPayloadSchema");
builder.AppendLine("{");
builder.AppendLine(
" private static readonly Dictionary<string, string[]> s_keys = new(StringComparer.Ordinal)"
);
builder.AppendLine(" {");

foreach (RequestDefinition reqDef in protocol.Requests)
{
List<FieldDefinition> fields = reqDef.ResponseFields ?? [];
if (fields.Count == 0)
{
continue;
}

// Nested fields arrive as "parent.child"; only the outermost name is a map key.
HashSet<string> keys = new(StringComparer.Ordinal);
foreach (FieldDefinition field in fields)
{
string name = field.ValueName;
int dot = name.IndexOf('.');
_ = keys.Add(dot >= 0 ? name.Substring(0, dot) : name);
}

string recordName = $"{SanitizeIdentifier(reqDef.RequestType)}ResponseData";
string list = string.Join(
", ",
keys.OrderBy(k => k, StringComparer.Ordinal).Select(k => $"\"{k}\"")
);
builder.AppendLine($" [\"{recordName}\"] = [{list}],");
}

builder.AppendLine(" };");
builder.AppendLine();
builder.AppendLine(" /// <summary>");
builder.AppendLine(
" /// The keys a response record expects, or an empty span when the record is unknown"
);
builder.AppendLine(" /// to the schema, in which case no check is possible.");
builder.AppendLine(" /// </summary>");
builder.AppendLine(
" /// <param name=\"responseTypeName\">The record's type name.</param>"
);
builder.AppendLine(" public static string[] KnownKeys(string responseTypeName) =>");
builder.AppendLine(
" s_keys.TryGetValue(responseTypeName, out string[]? keys) ? keys : [];"
);
builder.AppendLine("}");

context.AddSource(
"ObsWebSocketPayloadSchema.g.cs",
SourceText.From(builder.ToString(), Encoding.UTF8)
);
}
}
8 changes: 5 additions & 3 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.WaitForEvent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ ProtocolDefinition protocol
"/// Contains generated helper methods for the <see cref=\"ObsWebSocketClient\"/>."
);
builder.AppendLine("/// </summary>");
builder.AppendLine("public static partial class ObsWebSocketClientHelpers");
builder.AppendLine("public static partial class ObsWebSocketClientOperations");
builder.AppendLine("{");

// Generate the WaitForEventAsync method signature and documentation
Expand Down Expand Up @@ -253,7 +253,9 @@ ProtocolDefinition protocol
);
builder.AppendLine(" tcs.TrySetCanceled(linkedCts.Token);");
builder.AppendLine(
" throw new TimeoutException($\"Timed out after {timeout} waiting for {typeof(TEventArgs).Name}.\");"
// The library's own timeout type, so catching ObsWebSocketException covers a wait
// that timed out as well as a request that did.
" throw new ObsWebSocketTimeoutException($\"Timed out after {timeout} waiting for {typeof(TEventArgs).Name}.\");"
);
builder.AppendLine(" }");
builder.AppendLine(" catch (Exception ex)");
Expand Down Expand Up @@ -283,7 +285,7 @@ ProtocolDefinition protocol
builder.AppendLine(" }"); // End WaitForEventAsync method

// Close class and namespace
builder.AppendLine("}"); // End ObsWebSocketClientHelpers class
builder.AppendLine("}"); // End ObsWebSocketClientOperations class
// File-scoped namespace is assumed, no closing brace needed here

// Add the generated source file to the compilation
Expand Down
Loading
Loading