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()); } 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);