From 1378dbe2c4b02ac7e89cdc62038885ff06631084 Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 12:45:02 -0500 Subject: [PATCH 1/2] Generate ComposeDefaults enums via declarative attribute Replaces the seven hand-rolled `[Flags]` enums in ComposeDefaults.cs (`ButtonDefault`, `TextDefault`, `IconButtonDefault`, `FloatingActionButtonDefault`, `SurfaceDefault`, `AlertDialogDefault`, `TextFieldDefault`) with a new declarative form of `ComposeDefaultsAttribute` that takes a positional Kotlin parameter list: [assembly: ComposeDefaults("ButtonDefault", "!onClick", "modifier", "enabled", ..., "!content")] Names prefixed with `!` consume a bit position but emit no enum member, covering Compose params the caller always provides (onClick, content, text, value, etc.). Optional slot params stay as members so call sites can clear bits per-call (the AlertDialog pattern). These seven were hand-rolled because the dotnet/android-libraries binder strips the Compose overloads with mangled JVM names from inline classes (Text--4IGK_g, Surface-T9BRK9s, AlertDialog-Oix01E0, ...), so no IMethodSymbol exists for the existing generic `[ComposeDefaults]` form to read. The declarative form is a near-term workaround; once dotnet/java-interop#1440 lands and exposes those overloads, each declarative attribute can be swapped one-for-one for the generic form. Generator changes: - New non-generic `ComposeDefaultsAttribute(string, params string[])`. - `ComposeDefaultsGenerator` dispatches on attribute kind. Shared `ComposeDefaultsEmitter` core driven by a `Slot` list, populated either from `IMethodSymbol` (generic) or from the names array (declarative). - Two new tests covering the declarative path; existing eight unchanged. Verified the generated enums match the deleted hand-rolled values byte-for-byte (bit positions and `All` mask) by enabling EmitCompilerGeneratedFiles and diffing AlertDialogDefault and SurfaceDefault. Also adds .github/copilot-instructions.md documenting the layout, build/test commands, both attribute forms, the ComposeBridges JNI recipe, the facade conventions (ComposableNode/Container, ComposableLambdaN, named-slot AlertDialog pattern), the bindings policy, and style. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 180 ++++++++++++++++++ src/ComposeNet.Compose/ComposeDefaults.cs | 179 +++++------------ .../GeneratorTests.cs | 41 ++++ src/ComposeNet.SourceGenerators/Attributes.cs | 18 ++ .../ComposeDefaultsEmitter.cs | 93 ++++++--- .../ComposeDefaultsGenerator.cs | 59 +++++- 6 files changed, 404 insertions(+), 166 deletions(-) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..ee28b5a6 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,180 @@ +# compose-net — agent instructions + +A C#-only .NET-for-Android app hosting Jetpack Compose UI through the +official `Xamarin.AndroidX.Compose.*` bindings. No Kotlin sources, no +custom bindings, no `[InterceptsLocation]` magic. Every C# composable +either calls a generated binding method directly or a JNI bridge in +`ComposeBridges.cs`. + +Read `README.md` and `NOTES.md` for the why. This file documents the +conventions an agent **must** follow when changing code. + +## Layout + +- `src/ComposeNet.Compose/` — the public C# facade (`Text`, `Column`, + `Button`, `MaterialTheme`, `AlertDialog`, …). One file per concept; + `Composables.cs` holds the user-facing types, `ComposeBridges.cs` + holds raw-JNI bridges, `ComposeDefaults.cs` holds *only* assembly + attributes that drive the source generator. +- `src/ComposeNet.SourceGenerators/` — Roslyn incremental generator + (`ComposeDefaultsGenerator`) that emits `[Flags]` enums for Kotlin's + `$default` bitmask. +- `src/ComposeNet.SourceGenerators.Tests/` — xUnit tests, **no Android + SDK required**. Run with `dotnet test src/ComposeNet.SourceGenerators.Tests`. +- `src/ComposeNet.Sample/` — runnable Android app. Build with + `dotnet build src/ComposeNet.Sample` (needs the `android` workload). + +## Build / test / run + +```pwsh +dotnet test src/ComposeNet.SourceGenerators.Tests # generator unit tests +dotnet build src/ComposeNet.Compose # facade only +dotnet build src/ComposeNet.Sample # full Android build +dotnet build src/ComposeNet.Sample -t:Run # deploy to device +``` + +Run the generator tests on every change to `ComposeNet.SourceGenerators` +or `ComposeDefaults.cs`. Run the facade build on every change to +`ComposeNet.Compose`. + +## Generated `$default` enums — DO NOT hand-roll + +Every Compose `@Composable` function takes a trailing `int $default` +bitmask: bit *N* set means "parameter *N* was not supplied; use the +Kotlin default." We need a `[Flags]` enum naming each bit so call sites +read as `(int)ButtonDefault.All` instead of magic numbers. + +**Always generate these enums via `ComposeDefaultsAttribute`. Never +write a `[Flags] enum FooDefault { … }` by hand.** All such attributes +live in `src/ComposeNet.Compose/ComposeDefaults.cs`. + +Two forms, pick by whether the binding exposes the Kt method: + +### 1. Generic form — preferred when the Kt method is bindable + +```csharp +[assembly: ComposeDefaults("Column", "ColumnDefault")] +``` + +The generator picks the longest static overload of `ColumnKt.Column`, +walks parameters up to the first `IComposer`, names each bit after the +parameter (PascalCased), skips `Kotlin.Jvm.Functions.IFunction*` slots +(content lambdas — always provided), and emits an `All` constant. + +### 2. Declarative form — when the Kt method is stripped + +The dotnet/android-libraries binder strips Compose overloads with +mangled JVM names (`Text--4IGK_g`, `Surface-T9BRK9s`, `AlertDialog-Oix01E0`, +`FloatingActionButton-X-z6DiA`). These come from Kotlin `@JvmInline value +class` parameters (`Color`, `Dp`, `TextUnit`, `FontWeight`, …). Once +[dotnet/java-interop#1440] lands the generic form will work for them +too — until then, hand the generator the Kotlin parameter names: + +```csharp +[assembly: ComposeDefaults("ButtonDefault", + "!onClick", "modifier", "enabled", "shape", "colors", + "elevation", "border", "contentPadding", "interactionSource", "!content")] +``` + +- Each name occupies one bit at its positional index. +- Prefix with `!` to consume the bit but emit no enum member (params + the caller always provides — `onClick`, content lambdas, required + values like `text`, `value`). +- For optional slot lambdas the caller toggles per-call (e.g. + `AlertDialog`'s `dismissButton`/`icon`/`title`/`text`), keep them + *as enum members* — the call site clears the bit when the user + supplies the slot. + +When the upstream binder fix lands, a declarative attribute can be +swapped to the generic form one-for-one and the comment in +`ComposeDefaults.cs` can be updated. + +[dotnet/java-interop#1440]: https://github.com/dotnet/java-interop/pull/1440 + +### Generator diagnostics + +| ID | Meaning | +|---------|--------------------------------------------------------| +| CN1001 | Generic form: named static method not found on `T`. | +| CN1002 | Generic form: method has no `IComposer` parameter. | +| CN1003 | Either form: attribute arguments couldn't be read. | + +Tests live in `GeneratorTests.cs` and run against synthetic +compilations — no Android references. **Add a test for any new +generator behaviour.** + +## `ComposeBridges.cs` — raw JNI bridges + +When an overload is stripped from the binding we call it via +`JNIEnv.FindClass` + `GetStaticMethodID` + `CallStaticVoidMethod`. +Pattern, copied throughout the file: + +1. Cache the JNI class + method handles in `static IntPtr` fields + (initialise lazily on first call — Android's class loader is slow). +2. Build the full JNI signature string as a `const string` next to the + bridge method, with a comment showing the Kotlin parameter list in + source order. +3. Allocate `JValue* args = stackalloc JValue[N]`; fill positionally. +4. The `$default` bitmask is the **last** `int` arg (after any + `$changed`/`$changed1`/… and `composer`). Compute it from the + matching `XxxDefault.All` constant. +5. For string params: `IntPtr ref = JNIEnv.NewString(s);` inside a + `try`/`finally` that calls `DeleteLocalRef`. +6. Pass managed objects as `((Java.Lang.Object)obj).Handle`. Use + `IntPtr.Zero` for `null`. + +Keep these methods `internal` — user code never touches JNI directly. +**Do not add new JNI bridges if the binding already exposes the +method**; call the generated C# entry point instead (see +`Composables.cs::Column.Render` for the canonical example). + +## Facade conventions (`Composables.cs`) + +- All public types derive from `ComposableNode` (a single `internal + abstract void Render(IComposer)`). +- Container composables derive from `ComposableContainer`, which + implements `IEnumerable` + `Add(ComposableNode?)` so callers can use + C# collection-initializer syntax: + ```csharp + new Column { new Text("Hi"), new Button(onClick: …) { new Text("Tap") } } + ``` +- Wrap Kotlin lambdas with `ComposableLambda0/1/2/3` (existing + helpers — don't hand-roll new lambda adapters). +- Multi-slot composables (e.g. `AlertDialog`) expose **named slot + properties** set via object-initializer syntax, not extra + collection-init Add overloads. Pattern: start `defaults = + XxxDefault.All`, clear the bit for each slot the caller actually + supplied. See `AlertDialog.Render` — this is the template the future + `ModalBottomSheet`, `DatePickerDialog`, etc. will follow. +- Required Kotlin parameters with no default (e.g. `AlertDialog.confirmButton`) + are validated in `Render` with a clear `InvalidOperationException`. +- File-scoped namespaces (`namespace ComposeNet;`). One blank line + separating `// ---- Section ----` banners. XML doc comments on every + public type and every non-trivial member. + +## Bindings policy + +The repo used to ship its own `*.Compose.*` binding projects. **Don't +bring those back.** Reference the official NuGets only: +`Xamarin.AndroidX.Compose.*` and `Xamarin.AndroidX.Compose.Material3`. +If a needed Compose API isn't bound, the workflow is: + +1. File / link a tracking issue against `dotnet/android-libraries` (see + the existing #1415–#1418 references in `README.md`). +2. Add a JNI bridge in `ComposeBridges.cs` as a temporary measure. +3. Generate the matching `$default` enum via the declarative attribute + form above. +4. When the upstream binding fix ships, delete the bridge and switch + to the generic attribute form. + +## Style + +- Target framework: `net10.0-android` for the facade and sample; + `netstandard2.0` for the source generator (Roslyn requirement); + `net10.0` for the generator tests. +- C# 12+, nullable reference types enabled, file-scoped namespaces. +- Public API gets XML doc comments. Internal helpers get a one-line + `//` comment when they're non-obvious; otherwise leave them bare. +- Don't add markdown planning docs to the repo — use the session + artifact folder. +- Commit trailer: `Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>`. diff --git a/src/ComposeNet.Compose/ComposeDefaults.cs b/src/ComposeNet.Compose/ComposeDefaults.cs index 9dbdc232..da052678 100644 --- a/src/ComposeNet.Compose/ComposeDefaults.cs +++ b/src/ComposeNet.Compose/ComposeDefaults.cs @@ -5,15 +5,24 @@ // to query them. // // `ColumnDefault` and `MaterialThemeDefault` are *generated* by -// ComposeNet.SourceGenerators from the two assembly-level attributes -// below. The generator reads the longest overload of the named static -// method, names each bit after its parameter, and emits an `All` -// constant that ORs every user-defaultable bit. +// ComposeNet.SourceGenerators from the generic `[ComposeDefaults]` +// attribute below — the binder exposes those Kt classes, so the +// generator can read parameter names off the longest overload. // -// `ButtonDefault` and `TextDefault` are still HAND-ROLLED because the -// dotnet/android-libraries binding strips `ButtonKt.Button` and -// `TextKt.Text--4IGK_g` (we call them via raw JNI in `ComposeBridges`), -// so there's no C# `IMethodSymbol` for the generator to introspect. +// The other seven (Button, Text, IconButton, FloatingActionButton, +// Surface, AlertDialog, TextField/OutlinedTextField) are *also* +// generated, but from the declarative `[ComposeDefaults]` overload. +// Their Kotlin overloads with the trailing $default param are stripped +// from the binding (mangled JVM names like `Text--4IGK_g` from inline +// classes such as `Color`/`TextUnit`/`Dp`), so there is no IMethodSymbol +// for the generator to introspect — we hand it the Kotlin parameter +// names instead. Names prefixed with `!` consume a bit position but +// don't emit an enum member (e.g. params the caller always provides). +// +// When dotnet/java-interop#1440 lands and exposes the inline-class +// overloads, the declarative attributes can be replaced with +// `[ComposeDefaults("Button", "ButtonDefault")]` etc. and +// this comment can be deleted. using AndroidX.Compose.Foundation.Layout; using AndroidX.Compose.Material3; @@ -22,150 +31,54 @@ [assembly: ComposeDefaults("Column", "ColumnDefault")] [assembly: ComposeDefaults("MaterialTheme", "MaterialThemeDefault")] -namespace ComposeNet; - // androidx.compose.material3.ButtonKt.Button: 10 user params, // bit 0 = onClick, bit 9 = content (both always provided). -[System.Flags] -internal enum ButtonDefault -{ - None = 0, - Modifier = 1 << 1, - Enabled = 1 << 2, - Shape = 1 << 3, - Colors = 1 << 4, - Elevation = 1 << 5, - Border = 1 << 6, - ContentPadding = 1 << 7, - InteractionSource = 1 << 8, - All = Modifier | Enabled | Shape | Colors | Elevation | Border | ContentPadding | InteractionSource, -} +[assembly: ComposeDefaults("ButtonDefault", + "!onClick", "modifier", "enabled", "shape", "colors", + "elevation", "border", "contentPadding", "interactionSource", "!content")] // androidx.compose.material3.TextKt.Text--4IGK_g: 17 user params, // bit 0 = text (always provided). -[System.Flags] -internal enum TextDefault -{ - None = 0, - Modifier = 1 << 1, - Color = 1 << 2, - FontSize = 1 << 3, - FontStyle = 1 << 4, - FontWeight = 1 << 5, - FontFamily = 1 << 6, - LetterSpacing = 1 << 7, - Decoration = 1 << 8, - Align = 1 << 9, - LineHeight = 1 << 10, - Overflow = 1 << 11, - SoftWrap = 1 << 12, - MaxLines = 1 << 13, - MinLines = 1 << 14, - OnTextLayout = 1 << 15, - Style = 1 << 16, - All = Modifier | Color | FontSize | FontStyle | FontWeight | FontFamily | LetterSpacing - | Decoration | Align | LineHeight | Overflow | SoftWrap | MaxLines | MinLines - | OnTextLayout | Style, -} +[assembly: ComposeDefaults("TextDefault", + "!text", "modifier", "color", "fontSize", "fontStyle", + "fontWeight", "fontFamily", "letterSpacing", "decoration", "align", + "lineHeight", "overflow", "softWrap", "maxLines", "minLines", + "onTextLayout", "style")] // androidx.compose.material3.IconButtonKt.IconButton: 6 user params, // bit 0 = onClick, bit 5 = content (both provided). -[System.Flags] -internal enum IconButtonDefault -{ - None = 0, - Modifier = 1 << 1, - Enabled = 1 << 2, - Colors = 1 << 3, - InteractionSource = 1 << 4, - All = Modifier | Enabled | Colors | InteractionSource, -} +[assembly: ComposeDefaults("IconButtonDefault", + "!onClick", "modifier", "enabled", "colors", "interactionSource", "!content")] // androidx.compose.material3.FloatingActionButtonKt.FloatingActionButton-X-z6DiA: // 8 user params, bit 0 = onClick, bit 7 = content (both provided). -[System.Flags] -internal enum FloatingActionButtonDefault -{ - None = 0, - Modifier = 1 << 1, - Shape = 1 << 2, - ContainerColor = 1 << 3, - ContentColor = 1 << 4, - Elevation = 1 << 5, - InteractionSource = 1 << 6, - All = Modifier | Shape | ContainerColor | ContentColor | Elevation | InteractionSource, -} +[assembly: ComposeDefaults("FloatingActionButtonDefault", + "!onClick", "modifier", "shape", "containerColor", "contentColor", + "elevation", "interactionSource", "!content")] // androidx.compose.material3.SurfaceKt.Surface-T9BRK9s (non-interactive): // 8 user params, only bit 7 = content provided. -[System.Flags] -internal enum SurfaceDefault -{ - None = 0, - Modifier = 1 << 0, - Shape = 1 << 1, - Color = 1 << 2, - ContentColor = 1 << 3, - TonalElevation = 1 << 4, - ShadowElevation = 1 << 5, - Border = 1 << 6, - All = Modifier | Shape | Color | ContentColor | TonalElevation | ShadowElevation | Border, -} +[assembly: ComposeDefaults("SurfaceDefault", + "modifier", "shape", "color", "contentColor", "tonalElevation", + "shadowElevation", "border", "!content")] // androidx.compose.material3.AndroidAlertDialog_androidKt.AlertDialog-Oix01E0: // 14 user params, bit 0 = onDismissRequest, bit 1 = confirmButton // (both always provided). The four slot Function2 params (dismissButton, -// icon, title, text) are optional and toggled per-call by AlertDialog.Render. -[System.Flags] -internal enum AlertDialogDefault -{ - None = 0, - Modifier = 1 << 2, - DismissButton = 1 << 3, - Icon = 1 << 4, - Title = 1 << 5, - Text = 1 << 6, - Shape = 1 << 7, - ContainerColor = 1 << 8, - IconContentColor = 1 << 9, - TitleContentColor = 1 << 10, - TextContentColor = 1 << 11, - TonalElevation = 1 << 12, - Properties = 1 << 13, - All = Modifier | DismissButton | Icon | Title | Text | Shape | ContainerColor - | IconContentColor | TitleContentColor | TextContentColor | TonalElevation | Properties, -} +// icon, title, text) are toggled per-call by AlertDialog.Render — they +// stay as enum members so callers can OR them in conditionally. +[assembly: ComposeDefaults("AlertDialogDefault", + "!onDismissRequest", "!confirmButton", "modifier", "dismissButton", + "icon", "title", "text", "shape", "containerColor", "iconContentColor", + "titleContentColor", "textContentColor", "tonalElevation", "properties")] // androidx.compose.material3.TextFieldKt.TextField (String overload) AND // OutlinedTextFieldKt.OutlinedTextField (String overload): 23 user params, // bit 0 = value, bit 1 = onValueChange (both provided). -[System.Flags] -internal enum TextFieldDefault -{ - None = 0, - Modifier = 1 << 2, - Enabled = 1 << 3, - ReadOnly = 1 << 4, - TextStyle = 1 << 5, - Label = 1 << 6, - Placeholder = 1 << 7, - LeadingIcon = 1 << 8, - TrailingIcon = 1 << 9, - Prefix = 1 << 10, - Suffix = 1 << 11, - SupportingText = 1 << 12, - IsError = 1 << 13, - VisualTransformation = 1 << 14, - KeyboardOptions = 1 << 15, - KeyboardActions = 1 << 16, - SingleLine = 1 << 17, - MaxLines = 1 << 18, - MinLines = 1 << 19, - InteractionSource = 1 << 20, - Shape = 1 << 21, - Colors = 1 << 22, - All = Modifier | Enabled | ReadOnly | TextStyle | Label | Placeholder | LeadingIcon - | TrailingIcon | Prefix | Suffix | SupportingText | IsError | VisualTransformation - | KeyboardOptions | KeyboardActions | SingleLine | MaxLines | MinLines - | InteractionSource | Shape | Colors, -} +[assembly: ComposeDefaults("TextFieldDefault", + "!value", "!onValueChange", "modifier", "enabled", "readOnly", + "textStyle", "label", "placeholder", "leadingIcon", "trailingIcon", + "prefix", "suffix", "supportingText", "isError", "visualTransformation", + "keyboardOptions", "keyboardActions", "singleLine", "maxLines", "minLines", + "interactionSource", "shape", "colors")] + diff --git a/src/ComposeNet.SourceGenerators.Tests/GeneratorTests.cs b/src/ComposeNet.SourceGenerators.Tests/GeneratorTests.cs index ab53e96e..45567bd4 100644 --- a/src/ComposeNet.SourceGenerators.Tests/GeneratorTests.cs +++ b/src/ComposeNet.SourceGenerators.Tests/GeneratorTests.cs @@ -231,6 +231,47 @@ public static void Foo(int alpha, int p1, int gamma, Assert.Contains("All = Alpha | Param1 | Gamma,", emitted); } + [Fact] + public void DeclarativeAttribute_EmitsBitsFromNamesList() + { + var attr = """ + [assembly: ComposeNet.ComposeDefaults("ButtonDefault", + "!onClick", "modifier", "enabled", "shape", "!content")] + """; + + var (_, diags, emitted) = RunGenerator(attr, "// nothing"); + Assert.Empty(diags); + Assert.NotNull(emitted); + Assert.Contains("internal enum ButtonDefault", emitted); + // !onClick at bit 0 is consumed but no member emitted. + Assert.DoesNotContain("OnClick =", emitted); + Assert.Contains("// bit 0: onClick", emitted); + Assert.Contains("Modifier = 1 << 1,", emitted); + Assert.Contains("Enabled = 1 << 2,", emitted); + Assert.Contains("Shape = 1 << 3,", emitted); + // !content at bit 4 is consumed but no member emitted. + Assert.DoesNotContain("Content =", emitted); + Assert.Contains("// bit 4: content", emitted); + Assert.Contains("All = Modifier | Enabled | Shape,", emitted); + } + + [Fact] + public void DeclarativeAttribute_RequiresNoBindingSymbols() + { + // No synthetic Kt class — proves the declarative path doesn't need IMethodSymbol. + var attr = """ + [assembly: ComposeNet.ComposeDefaults("FooDefault", "alpha", "beta", "gamma")] + """; + + var (_, diags, emitted) = RunGenerator(attr, "// nothing"); + Assert.Empty(diags); + Assert.NotNull(emitted); + Assert.Contains("Alpha = 1 << 0,", emitted); + Assert.Contains("Beta = 1 << 1,", emitted); + Assert.Contains("Gamma = 1 << 2,", emitted); + Assert.Contains("All = Alpha | Beta | Gamma,", emitted); + } + [Fact] public void AttributeIsAddedViaPostInit() { diff --git a/src/ComposeNet.SourceGenerators/Attributes.cs b/src/ComposeNet.SourceGenerators/Attributes.cs index 6feb3b4e..29c4248f 100644 --- a/src/ComposeNet.SourceGenerators/Attributes.cs +++ b/src/ComposeNet.SourceGenerators/Attributes.cs @@ -25,6 +25,24 @@ internal sealed class ComposeDefaultsAttribute : global::System.Attribute { public ComposeDefaultsAttribute(string methodName, string enumName) { } } + + /// + /// Apply at assembly scope to generate a named-bit + /// [Flags] internal enum <EnumName> from an + /// explicit positional parameter list. Use this overload + /// when the Kotlin $default-bearing overload was + /// stripped from the binding (mangled JVM name, inline-class + /// param, etc.) and there is no IMethodSymbol to read. + /// Each name occupies one bit at its index; prefix a name + /// with ! to consume the bit without emitting an + /// enum member (e.g. params the caller always provides). + /// + [global::System.AttributeUsage(global::System.AttributeTargets.Assembly, + AllowMultiple = true)] + internal sealed class ComposeDefaultsAttribute : global::System.Attribute + { + public ComposeDefaultsAttribute(string enumName, params string[] parameterNames) { } + } } """; } diff --git a/src/ComposeNet.SourceGenerators/ComposeDefaultsEmitter.cs b/src/ComposeNet.SourceGenerators/ComposeDefaultsEmitter.cs index 0ff466e0..7531416f 100644 --- a/src/ComposeNet.SourceGenerators/ComposeDefaultsEmitter.cs +++ b/src/ComposeNet.SourceGenerators/ComposeDefaultsEmitter.cs @@ -7,30 +7,73 @@ namespace ComposeNet.SourceGenerators; internal static class ComposeDefaultsEmitter { - public static string Emit( - string enumName, - INamedTypeSymbol containingType, - IMethodSymbol method, - int composerIndex) + /// + /// One bit position in the generated enum. + /// is null when the bit is consumed (occupies + /// a position) but no member is emitted — e.g. params the caller always + /// provides, or Kotlin lambda content slots. + /// + public readonly struct Slot { - var emitted = new List<(string Name, int Bit)>(); + public Slot(int bit, string? enumMember, string rawName, string? skipReason) + { + Bit = bit; + EnumMember = enumMember; + RawName = rawName; + SkipReason = skipReason; + } + + public int Bit { get; } + public string? EnumMember { get; } + public string RawName { get; } + public string? SkipReason { get; } + } + + /// Build slots from a Roslyn method symbol (generic attribute path). + public static IReadOnlyList SlotsFromSymbol(IMethodSymbol method, int composerIndex) + { + var slots = new List(composerIndex); for (int i = 0; i < composerIndex; i++) { var p = method.Parameters[i]; if (ComposeDefaultsGenerator.IsKotlinFunction(p.Type)) + { + slots.Add(new Slot(i, null, p.Name, $"{p.Type.Name} — always provided, no enum member.")); continue; - emitted.Add((PascalCase(p.Name, i), i)); + } + slots.Add(new Slot(i, PascalCase(p.Name, i), p.Name, null)); } + return slots; + } + /// Build slots from a positional name list (declarative attribute path). + public static IReadOnlyList SlotsFromNames(IReadOnlyList names) + { + var slots = new List(names.Count); + for (int i = 0; i < names.Count; i++) + { + var raw = names[i] ?? string.Empty; + if (raw.Length > 0 && raw[0] == '!') + { + var inner = raw.Substring(1); + slots.Add(new Slot(i, null, inner, "marked '!' — always provided, no enum member.")); + } + else + { + slots.Add(new Slot(i, PascalCase(raw, i), raw, null)); + } + } + return slots; + } + + public static string Emit(string enumName, string sourceComment, IReadOnlyList slots) + { + var members = slots.Where(s => s.EnumMember is not null).ToList(); var sb = new StringBuilder(); sb.AppendLine("// "); sb.Append("// Generated by ComposeNet.SourceGenerators from "); - sb.Append(containingType.ToDisplayString()); - sb.Append('.'); - sb.AppendLine(method.Name); - sb.AppendLine("// Bit positions match the Kotlin parameter index before the IComposer"); - sb.AppendLine("// parameter; Function-typed parameters (content lambdas) are skipped"); - sb.AppendLine("// because they are always provided and never defaulted."); + sb.AppendLine(sourceComment); + sb.AppendLine("// Bit positions match the Kotlin parameter index in the $default bitmask."); sb.AppendLine("#nullable enable"); sb.AppendLine("namespace ComposeNet"); sb.AppendLine("{"); @@ -39,33 +82,30 @@ public static string Emit( sb.AppendLine(" {"); sb.AppendLine(" None = 0,"); - int maxNameLen = emitted.Count == 0 ? 0 : emitted.Max(e => e.Name.Length); - foreach (var (name, bit) in emitted) + int maxNameLen = members.Count == 0 ? 0 : members.Max(m => m.EnumMember!.Length); + foreach (var slot in members) { - sb.Append(" ").Append(name).Append(' ', maxNameLen - name.Length) - .Append(" = 1 << ").Append(bit).AppendLine(","); + sb.Append(" ").Append(slot.EnumMember).Append(' ', maxNameLen - slot.EnumMember!.Length) + .Append(" = 1 << ").Append(slot.Bit).AppendLine(","); } - // Note any skipped Function-typed parameters as a comment for traceability. - for (int i = 0; i < composerIndex; i++) + foreach (var slot in slots) { - var p = method.Parameters[i]; - if (!ComposeDefaultsGenerator.IsKotlinFunction(p.Type)) - continue; - sb.Append(" // bit ").Append(i).Append(": ").Append(p.Name) - .Append(" (").Append(p.Type.Name).AppendLine(") — always provided, no enum member."); + if (slot.EnumMember is not null) continue; + sb.Append(" // bit ").Append(slot.Bit).Append(": ").Append(slot.RawName) + .Append(" — ").AppendLine(slot.SkipReason ?? "skipped."); } sb.AppendLine(); sb.AppendLine(" /// OR of every user-defaultable bit."); - if (emitted.Count == 0) + if (members.Count == 0) { sb.AppendLine(" All = 0,"); } else { sb.Append(" All = "); - sb.Append(string.Join(" | ", emitted.Select(e => e.Name))); + sb.Append(string.Join(" | ", members.Select(m => m.EnumMember))); sb.AppendLine(","); } sb.AppendLine(" }"); @@ -94,3 +134,4 @@ static bool IsAllDigits(string s, int start) return start < s.Length; } } + diff --git a/src/ComposeNet.SourceGenerators/ComposeDefaultsGenerator.cs b/src/ComposeNet.SourceGenerators/ComposeDefaultsGenerator.cs index 2684f0b5..3dff1f64 100644 --- a/src/ComposeNet.SourceGenerators/ComposeDefaultsGenerator.cs +++ b/src/ComposeNet.SourceGenerators/ComposeDefaultsGenerator.cs @@ -17,7 +17,8 @@ namespace ComposeNet.SourceGenerators; [Generator(LanguageNames.CSharp)] public sealed class ComposeDefaultsGenerator : IIncrementalGenerator { - const string AttributeMetadataName = "ComposeNet.ComposeDefaultsAttribute`1"; + const string GenericAttributeMetadataName = "ComposeNet.ComposeDefaultsAttribute`1"; + const string DeclarativeAttributeMetadataName = "ComposeNet.ComposeDefaultsAttribute"; const string ComposerNamespace = "AndroidX.Compose.Runtime"; const string ComposerName = "IComposer"; const string KotlinFunctionNamespace = "Kotlin.Jvm.Functions"; @@ -45,8 +46,9 @@ public void Initialize(IncrementalGeneratorInitializationContext context) static ImmutableArray BuildAll(Compilation compilation) { - var attrSymbol = compilation.GetTypeByMetadataName(AttributeMetadataName); - if (attrSymbol is null) + var genericAttr = compilation.GetTypeByMetadataName(GenericAttributeMetadataName); + var declarativeAttr = compilation.GetTypeByMetadataName(DeclarativeAttributeMetadataName); + if (genericAttr is null && declarativeAttr is null) return ImmutableArray.Empty; var assemblyAttributes = compilation.Assembly.GetAttributes(); @@ -55,14 +57,26 @@ static ImmutableArray BuildAll(Compilation compilation) foreach (var attr in assemblyAttributes) { if (attr.AttributeClass is not { } attrClass) continue; - if (!SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, attrSymbol)) continue; - builder.Add(Build(attr)); + + if (genericAttr is not null && + SymbolEqualityComparer.Default.Equals(attrClass.ConstructedFrom, genericAttr)) + { + builder.Add(BuildFromSymbol(attr)); + continue; + } + + if (declarativeAttr is not null && + SymbolEqualityComparer.Default.Equals(attrClass, declarativeAttr)) + { + builder.Add(BuildFromNames(attr)); + continue; + } } return builder.ToImmutable(); } - static GenerationResult Build(AttributeData attr) + static GenerationResult BuildFromSymbol(AttributeData attr) { var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; @@ -106,7 +120,38 @@ attr.ConstructorArguments[1].Value is not string enumName || new[] { Diagnostic.Create(Diagnostics.NotComposable, loc, method.ToDisplayString()) }); } - var source = ComposeDefaultsEmitter.Emit(enumName, containingType, method, composerIndex); + var slots = ComposeDefaultsEmitter.SlotsFromSymbol(method, composerIndex); + var sourceComment = $"{containingType.ToDisplayString()}.{method.Name}"; + var source = ComposeDefaultsEmitter.Emit(enumName, sourceComment, slots); + return new GenerationResult(source, $"ComposeNet.{enumName}.g.cs", Array.Empty()); + } + + static GenerationResult BuildFromNames(AttributeData attr) + { + var loc = attr.ApplicationSyntaxReference?.GetSyntax().GetLocation() ?? Location.None; + + if (attr.ConstructorArguments.Length < 2 || + attr.ConstructorArguments[0].Value is not string enumName || + string.IsNullOrWhiteSpace(enumName)) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MalformedAttribute, loc, "") }); + } + + // params string[] arrives as a single TypedConstant of Kind=Array. + var arr = attr.ConstructorArguments[1]; + if (arr.Kind != TypedConstantKind.Array) + { + return new GenerationResult(null, null, + new[] { Diagnostic.Create(Diagnostics.MalformedAttribute, loc, enumName) }); + } + + var names = arr.Values + .Select(v => v.Value as string ?? string.Empty) + .ToArray(); + + var slots = ComposeDefaultsEmitter.SlotsFromNames(names); + var source = ComposeDefaultsEmitter.Emit(enumName, $"declarative names for '{enumName}'", slots); return new GenerationResult(source, $"ComposeNet.{enumName}.g.cs", Array.Empty()); } From 1b362f4d0270c090d30482e77cf780e08656cd3b Mon Sep 17 00:00:00 2001 From: Jonathan Peppers Date: Wed, 3 Jun 2026 12:53:27 -0500 Subject: [PATCH 2/2] Fix CN1003 message to cover both [ComposeDefaults] forms The diagnostic message previously said `[ComposeDefaults(string)]`, which was wrong for both forms: the generic overload takes two strings, and the declarative overload isn't generic at all. Make the message form-agnostic. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/ComposeNet.SourceGenerators/Diagnostics.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ComposeNet.SourceGenerators/Diagnostics.cs b/src/ComposeNet.SourceGenerators/Diagnostics.cs index 0d6f9a6f..f1ee8b1c 100644 --- a/src/ComposeNet.SourceGenerators/Diagnostics.cs +++ b/src/ComposeNet.SourceGenerators/Diagnostics.cs @@ -23,7 +23,7 @@ internal static class Diagnostics public static readonly DiagnosticDescriptor MalformedAttribute = new( id: "CN1003", title: "Malformed [ComposeDefaults] attribute", - messageFormat: "Could not read [ComposeDefaults(string)] on enum '{0}'", + messageFormat: "Could not read [ComposeDefaults] attribute arguments for enum '{0}'", category: "ComposeNet", defaultSeverity: DiagnosticSeverity.Error, isEnabledByDefault: true);