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
214 changes: 214 additions & 0 deletions ObsWebSocket.Codegen.Tasks/Generation/Emitter.FreeFormFields.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;

namespace ObsWebSocket.Codegen.Tasks.Generation;

internal static partial class Emitter
{
private const string FreeFormType = "System.Text.Json.JsonElement?";

/// <summary>
/// Emits typed readers for every field the protocol declares as <c>Object</c> or <c>Any</c>,
/// on every event payload and response that carries one.
/// </summary>
/// <remarks>
/// Those fields are free-form by definition: an input's settings depend on its kind, a vendor's
/// response on the vendor. The property stays a <c>JsonElement</c>, and these readers are what
/// turns it into the caller's own type, the same way the group helpers do for requests.
/// </remarks>
public static void GenerateFreeFormFieldReaders(
SourceProductionContext context,
ProtocolDefinition protocol
)
{
StringBuilder builder = new();
builder.AppendLine("// <auto-generated/>");
builder.AppendLine("#nullable enable");
builder.AppendLine();
builder.AppendLine("using System;");
builder.AppendLine("using System.Text.Json;");
builder.AppendLine("using System.Text.Json.Serialization.Metadata;");
builder.AppendLine();
builder.AppendLine($"namespace {ExtensionsNamespace};");
builder.AppendLine();
builder.AppendLine("/// <summary>");
builder.AppendLine(
"/// Typed readers for the free-form fields events and responses carry, such as an"
);
builder.AppendLine("/// input's settings or a vendor's response data.");
builder.AppendLine("/// </summary>");
AppendGeneratedAttributes(builder);
builder.AppendLine("public static class ObsWebSocketFreeFormFields");
builder.AppendLine("{");

int emitted = 0;

if (protocol.Events is not null)
{
foreach (
OBSEvent definition in protocol.Events.OrderBy(
e => e.EventType,
StringComparer.Ordinal
)
)
{
string name = $"{SanitizeIdentifier(definition.EventType)}Payload";
emitted += EmitFreeFormReaders(
context,
builder,
$"{GeneratedEventsNamespace}.{name}",
name,
"payload",
definition.DataFields
);
}
}

if (protocol.Requests is not null)
{
foreach (
RequestDefinition request in protocol.Requests.OrderBy(
r => r.RequestType,
StringComparer.Ordinal
)
)
{
string name = $"{SanitizeIdentifier(request.RequestType)}ResponseData";
emitted += EmitFreeFormReaders(
context,
builder,
$"{GeneratedResponsesNamespace}.{name}",
name,
"response",
request.ResponseFields
);
}
}

builder.AppendLine(
" private static T? Read<T>(JsonElement? element, JsonTypeInfo<T> typeInfo, string field, string owner)"
);
builder.AppendLine(" {");
builder.AppendLine(" ArgumentNullException.ThrowIfNull(typeInfo);");
builder.AppendLine(
" if (element is not { } value || value.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)"
);
builder.AppendLine(" {");
builder.AppendLine(" return default;");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" try");
builder.AppendLine(" {");
builder.AppendLine(" return value.Deserialize(typeInfo);");
builder.AppendLine(" }");
builder.AppendLine(
" catch (Exception ex) when (ex is JsonException or NotSupportedException or InvalidOperationException)"
);
builder.AppendLine(" {");
builder.AppendLine(" throw new ObsWebSocketSerializationException(");
builder.AppendLine(
" $\"Could not read '{field}' of {owner} as {typeof(T).Name}.\","
);
builder.AppendLine(" ex");
builder.AppendLine(" );");
builder.AppendLine(" }");
builder.AppendLine(" }");
builder.AppendLine("}");
builder.AppendLine();
builder.AppendLine($"// Free-form fields with typed readers: {emitted}");

context.AddSource(
"ObsWebSocketClient.FreeFormFields.g.cs",
SourceText.From(builder.ToString(), Encoding.UTF8)
);
}

/// <summary>
/// Emits one extension block for a payload, with a pair of readers per free-form field.
/// </summary>
private static int EmitFreeFormReaders(
SourceProductionContext context,
StringBuilder builder,
string payloadType,
string payloadName,
string parameterName,
IReadOnlyList<FieldDefinition>? fields
)
{
if (fields is null || fields.Count == 0)
{
return 0;
}

List<FieldDefinition> freeForm =
[
.. fields.Where(field =>
!field.ValueName.Contains('.')
&& MapProtocolTypeToCSharp(
context,
field,
payloadName,
reportDiagnostics: false
).CSharpType == FreeFormType
),
];

if (freeForm.Count == 0)
{
return 0;
}

builder.AppendLine($" extension({payloadType} {parameterName})");
builder.AppendLine(" {");
foreach (FieldDefinition field in freeForm)
{
string wire = field.ValueName;
string property = SanitizeIdentifier(ToPascalCase(wire));
string reader = $"Get{property}";
string read = $"Read({parameterName}.{property}, {{0}}, \"{wire}\", \"{payloadName}\")";

builder.AppendLine(
$" /// <summary>Reads <c>{wire}</c> as <typeparamref name=\"T\"/>, using metadata the caller supplies.</summary>"
);
builder.AppendLine(
" /// <typeparam name=\"T\">The shape to read the field as.</typeparam>"
);
builder.AppendLine(
" /// <param name=\"typeInfo\">Metadata for <typeparamref name=\"T\"/>, typically from your own <c>JsonSerializerContext</c>.</param>"
);
builder.AppendLine(
" /// <returns>The value, or <see langword=\"default\"/> when OBS sent none.</returns>"
);
builder.AppendLine(
" /// <exception cref=\"ObsWebSocketSerializationException\">Thrown when the field does not have that shape.</exception>"
);
builder.AppendLine(
$" public T? {reader}<T>(JsonTypeInfo<T> typeInfo) => {string.Format(System.Globalization.CultureInfo.InvariantCulture, read, "typeInfo")};"
);
builder.AppendLine();
builder.AppendLine(
$" /// <summary>Reads <c>{wire}</c> as a settings type this library registers.</summary>"
);
builder.AppendLine(
" /// <typeparam name=\"T\">A library-registered settings type.</typeparam>"
);
builder.AppendLine(
" /// <returns>The value, or <see langword=\"null\"/> when OBS sent none.</returns>"
);
builder.AppendLine(
" /// <exception cref=\"ObsWebSocketException\">Thrown when <typeparamref name=\"T\"/> is not registered, or the field does not have that shape.</exception>"
);
builder.AppendLine($" public T? {reader}<T>()");
builder.AppendLine(" where T : class =>");
builder.AppendLine(
$" {string.Format(System.Globalization.CultureInfo.InvariantCulture, read, "ObsWebSocketClientOperations.GetRegisteredTypeInfo<T>()")};"
);
builder.AppendLine();
}

builder.AppendLine(" }");
builder.AppendLine();
return freeForm.Count;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ IReadOnlyList<Diagnostic> Diagnostics
Emitter.GenerateClientExtensions(context, protocol);
Emitter.GenerateHandleOverloads(context, protocol);
Emitter.GeneratePayloadHandles(context, protocol);
Emitter.GenerateFreeFormFieldReaders(context, protocol);
Emitter.GenerateEventPayloads(context, protocol);
Emitter.GenerateEventArgs(context, protocol);
Emitter.GenerateClientEventInfrastructure(context, protocol);
Expand Down
12 changes: 11 additions & 1 deletion ObsWebSocket.Codegen.Tasks/ProtocolCodegenRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,17 @@ string existingFile in Directory.GetFiles(
string normalizedRelativePath = NormalizeRelativePath(relativePath);
string outputPath = Path.Combine(outputDirectory, normalizedRelativePath);
_ = Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
File.WriteAllText(outputPath, source, new UTF8Encoding(false));
// One newline per file. StringBuilder.AppendLine writes the OS newline while protocol
// descriptions carry a bare "\n", which left mixed endings that showed as a change on
// every regeneration. The OS newline is what git checks out on that OS, and git stores
// LF either way.
File.WriteAllText(
outputPath,
source
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace("\n", Environment.NewLine, StringComparison.Ordinal),
new UTF8Encoding(false)
);
}
}

Expand Down
Loading
Loading