From 4d8c04eeec56c389090e38fd86eb596808a90280 Mon Sep 17 00:00:00 2001 From: Tim Miller Date: Mon, 13 Apr 2026 22:17:55 +0900 Subject: [PATCH] Fix Cbor List Array Generics for Real this time --- CarpaNet.Samples.slnx | 1 + samples/MebiByteTest/MebiByteTest.csproj | 26 + samples/MebiByteTest/Program.cs | 2 + samples/MebiByteTest/README.md | 3 + .../lexicons/social/mebibyte/bff/defs.json | 677 ++++++++++++++++++ .../lexicons/social/mebibyte/feed/byte.json | 59 ++ .../Generation/CborContextGenerator.cs | 6 + .../Generation/CborContextGeneratorTests.cs | 62 +- 8 files changed, 827 insertions(+), 9 deletions(-) create mode 100644 samples/MebiByteTest/MebiByteTest.csproj create mode 100644 samples/MebiByteTest/Program.cs create mode 100644 samples/MebiByteTest/README.md create mode 100644 samples/MebiByteTest/lexicons/social/mebibyte/bff/defs.json create mode 100644 samples/MebiByteTest/lexicons/social/mebibyte/feed/byte.json diff --git a/CarpaNet.Samples.slnx b/CarpaNet.Samples.slnx index 92984d7..8b28771 100644 --- a/CarpaNet.Samples.slnx +++ b/CarpaNet.Samples.slnx @@ -12,6 +12,7 @@ + diff --git a/samples/MebiByteTest/MebiByteTest.csproj b/samples/MebiByteTest/MebiByteTest.csproj new file mode 100644 index 0000000..567f277 --- /dev/null +++ b/samples/MebiByteTest/MebiByteTest.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + enable + enable + true + true + ATProtoJsonContext + true + + + + + + + + + + + + + + diff --git a/samples/MebiByteTest/Program.cs b/samples/MebiByteTest/Program.cs new file mode 100644 index 0000000..ae3f5df --- /dev/null +++ b/samples/MebiByteTest/Program.cs @@ -0,0 +1,2 @@ +// MebiByteTest - Verify source generation for MebiByte lexicons +Console.WriteLine("MebiByteTest: Source generation succeeded."); diff --git a/samples/MebiByteTest/README.md b/samples/MebiByteTest/README.md new file mode 100644 index 0000000..5446f8a --- /dev/null +++ b/samples/MebiByteTest/README.md @@ -0,0 +1,3 @@ +# MebiByteTest + +This is to test complex, locally referenced, lexicon entries to validate that they compile. \ No newline at end of file diff --git a/samples/MebiByteTest/lexicons/social/mebibyte/bff/defs.json b/samples/MebiByteTest/lexicons/social/mebibyte/bff/defs.json new file mode 100644 index 0000000..06d54fe --- /dev/null +++ b/samples/MebiByteTest/lexicons/social/mebibyte/bff/defs.json @@ -0,0 +1,677 @@ +{ + "lexicon": 1, + "id": "social.mebibyte.bff.defs", + "description": "Shared definitions for the Byte File Format (bff): colors, frames, transforms, and the renderable object variants. Because Lexicon has no float primitive, float-valued scalars and float-array entries are typed as `unknown`; consumers must enforce ranges (color channels and opacity in [0.0, 1.0], size/padding > 0, etc.). The bff `type` discriminator is replaced by atproto's `$type` when records are serialized.", + "defs": { + "color": { + "type": "array", + "description": "RGBA color expressed as four floats in [0.0, 1.0]: [R, G, B, A].", + "items": { + "type": "string", + "description": "Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "minLength": 4, + "maxLength": 4 + }, + "frame": { + "type": "array", + "description": "Position and size of an object: [X, Y, WIDTH, HEIGHT] in points on the 324x570 stage.", + "items": { + "type": "string", + "description": "Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "minLength": 4, + "maxLength": 4 + }, + "transformRow": { + "type": "array", + "description": "One row of a 3x2 affine transform: [A, B] (or [C, D], or [TX, TY]).", + "items": { + "type": "string", + "description": "Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "minLength": 2, + "maxLength": 2 + }, + "transform": { + "type": "array", + "description": "Top two rows of a 3x3 affine transformation matrix applied after `frame` is set: [[A, B], [C, D], [TX, TY]].", + "items": { + "type": "ref", + "ref": "#transformRow" + }, + "minLength": 3, + "maxLength": 3 + }, + "effect": { + "type": "string", + "description": "Animation effect id applied to an object and looped indefinitely. Multiple effects can be combined.", + "knownValues": [ + "sin", + "cos", + "wave", + "rotate", + "soon", + "fireworks" + ], + "maxLength": 512 + }, + "textAttribute": { + "type": "object", + "description": "A styling attribute applied to a substring of paragraph text.", + "required": [ + "type", + "range" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "bold", + "italic", + "bold-italic" + ], + "maxLength": 32 + }, + "range": { + "type": "array", + "description": "[startIndex, length] in utf16 code units.", + "items": { + "type": "integer", + "minimum": 0 + }, + "minLength": 2, + "maxLength": 2 + } + } + }, + "paragraph": { + "type": "object", + "description": "The Paragraph object renders text that flows and automatically wraps inside its frame. Best for text longer than 1-3 words.", + "required": [ + "frame", + "text" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "description": "A unique name or ID for the object. Case sensitive.", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "description": "Optional web url linking to the original source of the object.", + "maxLength": 2048 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The text to render. Cannot be purely whitespace.", + "maxLength": 3000 + }, + "color": { + "type": "ref", + "ref": "#color" + }, + "style": { + "type": "string", + "description": "Font style. Paragraph supports sans or serif.", + "knownValues": [ + "sans", + "serif" + ], + "default": "sans", + "maxLength": 32 + }, + "size": { + "type": "string", + "description": "Point size to render the text as a float > 0. Default 17.0. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "alignment": { + "type": "string", + "enum": [ + "left", + "center", + "right" + ], + "default": "left", + "maxLength": 16 + }, + "attributes": { + "type": "array", + "description": "Styling attributes applied to ranges of the text (bold, italic, bold-italic).", + "items": { + "type": "ref", + "ref": "#textAttribute" + } + } + } + }, + "text": { + "type": "object", + "description": "The Text object renders text that dynamically resizes to fill its frame. Always centered. Best for 1-3 words.", + "required": [ + "frame", + "text" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "text": { + "type": "string", + "minLength": 1, + "description": "The text to render. Cannot be purely whitespace.", + "maxLength": 3000 + }, + "color": { + "type": "ref", + "ref": "#color" + }, + "style": { + "type": "string", + "description": "Font style. Text supports a wider range of display faces than Paragraph.", + "knownValues": [ + "sans", + "serif", + "mono", + "eightbit", + "punchout", + "cursive", + "poster", + "tape", + "book" + ], + "default": "sans", + "maxLength": 32 + }, + "wordWrap": { + "type": "string", + "description": "Wrapping policy. `auto` lets the renderer decide; `manual` only wraps at explicit line breaks. Serialized as `word-wrap` in legacy bff.", + "enum": [ + "auto", + "manual" + ], + "default": "auto", + "maxLength": 16 + }, + "padding": { + "type": "string", + "description": "Padding (float >= 0) applied inside the frame while sizing the text. Default 20.0. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + } + } + }, + "link": { + "type": "object", + "description": "A pressable object that redirects the viewer to another Byte or website.", + "required": [ + "frame", + "url" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "url": { + "type": "string", + "format": "uri", + "description": "Destination URL. Both `byte://` and `http(s)://` are supported. Use `byte://byte.{BYTE_ID}` to link to a Byte by id.", + "maxLength": 2048 + }, + "title": { + "type": "string", + "description": "Title shown on the link.", + "maxLength": 300 + }, + "description": { + "type": "string", + "description": "Short description of the destination content.", + "maxLength": 1000 + }, + "color": { + "type": "ref", + "ref": "#color" + }, + "style": { + "type": "string", + "knownValues": [ + "sans", + "serif" + ], + "default": "sans", + "maxLength": 32 + } + } + }, + "image": { + "type": "object", + "description": "An inline PNG, JPEG, or BMP image.", + "required": [ + "frame", + "src" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "src": { + "type": "blob", + "description": "The image asset (PNG, JPEG, or BMP).", + "accept": ["image/png", "image/jpeg", "image/bmp"], + "maxSize": 2000000 + }, + "scaleMode": { + "type": "string", + "description": "How the image is resized inside its frame.", + "enum": [ + "fit", + "fill" + ], + "default": "fill", + "maxLength": 16 + } + } + }, + "graphic": { + "type": "object", + "description": "A monochromatic PNG that can be dynamically tinted. Always scales to fit its frame.", + "required": [ + "frame", + "src" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "src": { + "type": "blob", + "description": "The monochrome PNG asset.", + "accept": ["image/png"], + "maxSize": 1000000 + }, + "color": { + "type": "ref", + "ref": "#color" + } + } + }, + "gif": { + "type": "object", + "description": "An inline animated GIF.", + "required": [ + "frame", + "src" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "src": { + "type": "blob", + "description": "The GIF asset.", + "accept": ["image/gif"], + "maxSize": 4000000 + }, + "scaleMode": { + "type": "string", + "enum": [ + "fit", + "fill" + ], + "default": "fit", + "maxLength": 16 + } + } + }, + "video": { + "type": "object", + "description": "An inline MP4 video. Videos autoplay and loop on completion; 10 seconds or less is recommended.", + "required": [ + "frame", + "src" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "src": { + "type": "blob", + "description": "The MP4 video asset.", + "accept": ["video/mp4"], + "maxSize": 50000000 + }, + "muted": { + "type": "boolean", + "description": "Whether the video should play with sound muted.", + "default": true + } + } + }, + "musicHit": { + "type": "object", + "description": "A single note or drum hit inside a music beat.", + "required": [ + "time", + "type", + "bank", + "note", + "velo", + "duration" + ], + "properties": { + "time": { + "type": "string", + "description": "Time offset in bars as a float >= 0. May be subdivided to half/quarter/8th/16th/32nd notes. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "type": { + "type": "integer", + "description": "0 = instrument, 1 = drum.", + "enum": [ + 0, + 1 + ] + }, + "bank": { + "type": "string", + "description": "When `type` is 0, the instrument bank (e.g. bleep, meow, bass). When `type` is 1, always 'drums'.", + "knownValues": [ + "bleep", + "meow", + "bass", + "ping", + "string", + "reso", + "arp", + "bark", + "mono1", + "mono2", + "mono3", + "funk", + "sax", + "bell", + "roboto", + "do", + "drums" + ], + "maxLength": 16 + }, + "note": { + "type": "string", + "description": "When `type` is 0, a pitch like 'B/3' or 'C#/3' (A/-1 through G/8). When `type` is 1, a drum name like 'Kick' or 'Snare'.", + "maxLength": 16 + }, + "velo": { + "type": "integer", + "description": "Velocity (loudness) from 0 to 127.", + "minimum": 0, + "maximum": 127 + }, + "duration": { + "type": "string", + "description": "Duration in bars as a float > 0. Unsupported in current renderers; should be 1. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + } + } + }, + "musicBeat": { + "type": "object", + "description": "A single beat of a music sequence. Wraps the hit array because Lexicon arrays cannot nest directly. The beat's position in `music.instructions` determines its point on the 4:4 timeline.", + "required": [ + "hits" + ], + "properties": { + "hits": { + "type": "array", + "items": { + "type": "ref", + "ref": "#musicHit" + } + } + } + }, + "music": { + "type": "object", + "description": "A MIDI-style sequence played by the Byte soundfont. Autoplays and loops. Only one music object is permitted per Byte.", + "required": [ + "frame", + "bpm", + "length", + "instructions" + ], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "name": { + "type": "string", + "maxLength": 256 + }, + "transform": { + "type": "ref", + "ref": "#transform" + }, + "opacity": { + "type": "string", + "description": "Alpha value as a float in [0.0, 1.0]. Encoded as a decimal string because Lexicon lacks a float primitive.", + "maxLength": 32 + }, + "effects": { + "type": "array", + "items": { + "type": "ref", + "ref": "#effect" + } + }, + "originalSrc": { + "type": "string", + "format": "uri", + "maxLength": 2048 + }, + "bpm": { + "type": "integer", + "description": "Beats per minute. Must be greater than 0.", + "minimum": 1 + }, + "length": { + "type": "integer", + "description": "Length of the sequence in bars. Valid values are 2-16 and must be a multiple of 2.", + "minimum": 2, + "maximum": 16 + }, + "instructions": { + "type": "array", + "description": "The beats of the sequence. Length must be at least (length * 4).", + "items": { + "type": "ref", + "ref": "#musicBeat" + }, + "minLength": 8 + } + } + } + } +} diff --git a/samples/MebiByteTest/lexicons/social/mebibyte/feed/byte.json b/samples/MebiByteTest/lexicons/social/mebibyte/feed/byte.json new file mode 100644 index 0000000..b9116ae --- /dev/null +++ b/samples/MebiByteTest/lexicons/social/mebibyte/feed/byte.json @@ -0,0 +1,59 @@ +{ + "lexicon": 1, + "id": "social.mebibyte.feed.byte", + "defs": { + "main": { + "type": "record", + "description": "A Byte: a multimedia micro-scene authored in the Byte File Format (bff), rendered on a 324x570 stage with top-left-aligned coordinates.", + "key": "tid", + "record": { + "type": "object", + "required": ["version", "objects", "createdAt"], + "properties": { + "version": { + "type": "string", + "description": "BFF version in 'MAJOR.MINOR' form. MAJOR bumps are incompatible with older renderers; MINOR bumps add backwards-compatible functionality.", + "default": "1.1", + "maxLength": 16 + }, + "background": { + "type": "array", + "description": "Background color(s) of the Byte. One entry is a solid fill; two entries form a linear gradient. Defaults to a single opaque white ([1,1,1,1]) if omitted.", + "items": { "type": "ref", "ref": "social.mebibyte.bff.defs#color" }, + "minLength": 1, + "maxLength": 2 + }, + "objects": { + "type": "array", + "description": "Ordered list of renderable bff objects. Rendered back-to-front in array order.", + "items": { + "type": "union", + "refs": [ + "social.mebibyte.bff.defs#paragraph", + "social.mebibyte.bff.defs#text", + "social.mebibyte.bff.defs#link", + "social.mebibyte.bff.defs#image", + "social.mebibyte.bff.defs#graphic", + "social.mebibyte.bff.defs#gif", + "social.mebibyte.bff.defs#video", + "social.mebibyte.bff.defs#music" + ], + "closed": false + } + }, + "caption": { + "type": "string", + "description": "Optional caption shown alongside the Byte.", + "maxLength": 3000, + "maxGraphemes": 300 + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Client-declared timestamp when this Byte was originally authored." + } + } + } + } + } +} diff --git a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs index 71a0894..b90aa09 100644 --- a/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs +++ b/src/CarpaNet.SourceGen/Generation/CborContextGenerator.cs @@ -537,6 +537,12 @@ private static string GetConverterExpressionForArrayRef(string refString, string return ListConverterRef(itemTypeName, CborTypeInfoRef(itemTypeName)); } + if (itemRefKind == LexiconTypeKind.Array) + { + var innerConverter = GetConverterExpressionForArrayRef(items.Ref!, arrayNsid, registry); + return ListConverterRef(itemTypeName, innerConverter); + } + var itemConverter = GetPrimitiveConverterForRef(items.Ref!, arrayNsid, registry); return ListConverterRef(itemTypeName, itemConverter); } diff --git a/tests/CarpaNet.UnitTests/Generation/CborContextGeneratorTests.cs b/tests/CarpaNet.UnitTests/Generation/CborContextGeneratorTests.cs index fa8f01a..70f4061 100644 --- a/tests/CarpaNet.UnitTests/Generation/CborContextGeneratorTests.cs +++ b/tests/CarpaNet.UnitTests/Generation/CborContextGeneratorTests.cs @@ -11,10 +11,6 @@ public class CborContextGeneratorTests [Fact] public void ArrayOfArrayRef_UsesListConverterNotStringConverter() { - // Arrange: reproduce the MebiByte pattern where "transform" is an array - // of "transformRow" refs, and "transformRow" is itself an array of strings. - // Without the fix, the inner List element gets a StringCborConverter - // instead of a CborListTypeInfo. var registry = new TypeRegistry(); var doc = new LexiconDocument { @@ -57,11 +53,59 @@ public void ArrayOfArrayRef_UsesListConverterNotStringConverter() var result = sb.ToString(); - // Assert: the transform property should wrap the inner List in a - // CborListTypeInfo, not pass a StringCborConverter directly as the element - // converter for List>. - // Correct: CborListTypeInfo>( CborListTypeInfo( StringCborConverter ) ) - // Wrong: CborListTypeInfo>( StringCborConverter ) + Assert.Contains("new CarpaNet.Cbor.CborListTypeInfo>(new CarpaNet.Cbor.CborListTypeInfo(new CarpaNet.Cbor.Converters.StringCborConverter()))", result); + } + + [Fact] + public void RefToArrayOfArrayRefs_UsesListConverterNotStringConverter() + { + var registry = new TypeRegistry(); + var doc = new LexiconDocument + { + Id = "test.nested.refarray", + Defs = new Dictionary + { + ["transformRow"] = new LexiconDefinition + { + Type = "array", + Items = new LexiconDefinition { Type = "string" }, + }, + ["transform"] = new LexiconDefinition + { + Type = "array", + Items = new LexiconDefinition + { + Type = "ref", + Ref = "#transformRow", + }, + }, + ["main"] = new LexiconDefinition + { + Type = "object", + Properties = new Dictionary + { + ["transform"] = new LexiconDefinition + { + Type = "ref", + Ref = "#transform", + }, + }, + }, + }, + }; + registry.RegisterDocument(doc); + + var sb = new SourceBuilder(); + var options = new GeneratorOptions(); + var mainDef = doc.Defs["main"]; + + // Act + CborContextGenerator.GenerateCborTypeInfo( + sb, "TestNested.Refarray.DefsMain", "TestNested_Refarray_DefsMain", + mainDef, "test.nested.refarray", registry, options); + + var result = sb.ToString(); + Assert.Contains("new CarpaNet.Cbor.CborListTypeInfo>(new CarpaNet.Cbor.CborListTypeInfo(new CarpaNet.Cbor.Converters.StringCborConverter()))", result); } }