-
Notifications
You must be signed in to change notification settings - Fork 0
Source Generators
- Overview
- 1. PluginRegistrar
- 2. ManifestModelSourceGenerator
- 3. SettingsPopulatorGenerator
- 4. SdpiGenerator
- How the generators interact at runtime
- Adding a new generator
This document describes the four .NET source generators in the Cmpnnt.SdTools.SourceGenerators project: what problems they solve, how they work and how consumer plugin projects use them.
All four generators live in Cmpnnt.SdTools.SourceGenerators/ and are wired into consumer projects as Roslyn analyzers (not regular project references). This means they run inside the compiler during build and emit .g.cs files that participate in compilation.
graph LR
subgraph "Cmpnnt.SdTools.SourceGenerators"
PR[PluginRegistrar]
MG[ManifestModelSourceGenerator]
SP[SettingsPopulatorGenerator]
SG[SdpiGenerator]
end
subgraph "Consumer Plugin Project"
PA[PluginAction classes]
SC[Settings classes]
UI[UI component fields]
end
subgraph "Generated Output"
G1["PluginActionIdRegistry.g.cs"]
G2["GeneratedManifestModel.g.cs"]
G3["*.PopulateFromJson.g.cs"]
G4["GeneratedSdpiComponents.g.cs"]
end
PA --> PR --> G1
PA --> MG --> G2
SC --> SP --> G3
UI --> SG --> G4
Consumer projects reference the generator assembly as an analyzer, not a compile-time dependency:
<!-- Dev.Cmpnnt.SamplePlugin.csproj -->
<ProjectReference Include="..\Cmpnnt.SdTools.SourceGenerators\Cmpnnt.SdTools.SourceGenerators.csproj"
ReferenceOutputAssembly="false"
OutputItemType="Analyzer" />To inspect generated output on disk, the consumer enables EmitCompilerGeneratedFiles:
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>Generated files appear under:
obj/Generated/Cmpnnt.SdTools.SourceGenerators/
Cmpnnt.SdTools.SourceGenerators.PluginRegistrar/
Cmpnnt.SdTools.SourceGenerators.ManifestModelSourceGenerator/
Cmpnnt.SdTools.SourceGenerators.SettingsPopulatorGenerator/
Cmpnnt.SdTools.SourceGenerators.Sdpi.SdpiGenerator/
-
File:
ActionRegistrar.cs -
Template:
Templates/PluginActionIdRegistryTemplate.cs -
Generated file:
PluginActionIdRegistry.g.cs
The Stream Deck runtime routes events to plugin actions by string action ID (e.g. "cmpnnt.sdtools.sampleplugin.pluginaction"). The host needs a factory that maps these IDs to concrete class constructors. Without code generation, this requires Activator.CreateInstance or a switch statement — both are either incompatible with AOT or error-prone.
flowchart TD
A["CreateSyntaxProvider: find all ClassDeclarationSyntax"] --> B["Filter: implements ICommonPluginFunctions?"]
B --> C["Extract fully-qualified class name"]
C --> D["Pass to PluginActionIdRegistryTemplate"]
D --> E["Emit PluginActionIdRegistry.g.cs"]
- The generator scans every class declaration in the compilation.
- For each non-abstract class implementing
Cmpnnt.SdTools.Backend.ICommonPluginFunctions, it collects the fully-qualified class name. - The template emits a
PluginActionIdRegistryclass implementingIPluginActionRegistrywith:- A
FrozenSet<string>of all action IDs (lowercase fully-qualified class names) - A
FrozenDictionary<string, Func<ISdConnection, InitialPayload, ICommonPluginFunctions>>mapping each ID to anew ClassName(conn, payload)factory lambda
- A
// PluginActionIdRegistry.g.cs (abridged)
public class PluginActionIdRegistry : IPluginActionRegistry
{
private static readonly FrozenDictionary<string, Func<ISdConnection, InitialPayload, ICommonPluginFunctions>>
_actionFactories = CreateFactories();
private static readonly FrozenSet<string> _actionIds = CreateActionIdSet();
public ICommonPluginFunctions CreateAction(string actionId, ISdConnection connection, InitialPayload payload)
{
if (_actionFactories.TryGetValue(actionId, out var factory))
return factory(connection, payload);
return null;
}
private static FrozenDictionary<...> CreateFactories()
{
var factories = new Dictionary<...>()
{
["cmpnnt.sdtools.sampleplugin.pluginaction"] = (conn, load) => new PluginAction(conn, load),
["cmpnnt.sdtools.sampleplugin.pluginaction2"] = (conn, load) => new PluginAction2(conn, load),
["cmpnnt.sdtools.sampleplugin.pluginaction3"] = (conn, load) => new PluginAction3(conn, load),
};
return factories.ToFrozenDictionary();
}
}The generated registry is passed into the SDK entry point in Program.cs:
// Program.cs
SdWrapper.Run(args, new PluginActionIdRegistry());PluginContainer then calls registry.CreateAction(actionId, connection, payload) each time the Stream Deck software instantiates a new action tile.
-
File:
ManifestGenerator.cs -
Template:
Templates/ManifestProviderTemplate.cs -
Generated file:
GeneratedManifestModel.g.cs
For full usage instructions, attribute reference, and examples see ManifestGeneration.md.
Stream Deck plugins require a manifest.json describing the plugin's actions, their capabilities and metadata. Keeping this JSON file in sync with the actual C# action classes is tedious and error-prone.
flowchart TD
A["AnalyzerConfigOptionsProvider: read MSBuild properties\n(AssemblyName, Version, Authors, Description, PackageProjectUrl)"] --> E
B["Scan assembly + class attributes for\n[StreamDeckPlugin(...)]"] --> E
C["Scan all classes implementing ICommonPluginFunctions\n→ also check IKeypadPlugin / IEncoderPlugin\n→ read [StreamDeckAction(...)] per class"] --> E
D["Find ManifestConfigBase subclass (POCO)\n→ capture fully-qualified class name"] --> E
E["ManifestProviderTemplate.Generate(input)\n→ emit GeneratedManifestModel.g.cs"]
Values are resolved in priority order: POCO > attribute > MSBuild > convention.
-
MSBuild props:
AssemblyName,Version,Authors,Description, andPackageProjectUrlare read viaCompilerVisiblePropertyitems. -
[StreamDeckPlugin]: Assembly- or class-level attribute that overrides MSBuild values for plugin-level fields (name, UUID, icons, OS versions, etc.). -
Action discovery: Scans all non-abstract classes implementing
ICommonPluginFunctions. For each, checksIKeypadPlugin/IEncoderPluginto populate theControllersarray, and reads[StreamDeckAction]for per-action overrides. -
ManifestConfigBasesubclass: If a concrete subclass is found in the compilation, its fully-qualified name is captured. The template emitsvar userConfig = new UserConfigClass();insideGetManifestData(), and appliesDefaultStates,DefaultEncoder,ApplicationsToMonitor, andProfilesfrom it — these take highest priority. - The template emits a
GeneratedManifest.ManifestProviderclass. A separate MSBuild task (GenerateManifest) loads the compiled plugin DLL, callsManifestProvider.GetManifestData()via reflection, and serializes the result tomanifest.json.
// GeneratedManifestModel.g.cs (abridged)
namespace GeneratedManifest
{
internal static class ManifestProvider
{
public static ManifestRoot GetManifestData()
{
var root = new ManifestRoot();
var userConfig = new MyPlugin.SampleManifestConfig();
root.Name = "SDTools Sample Plugin";
root.Version = "1.0.0";
root.Uuid = "com.cmpnnt.streamdecktoolkit.sampleplugin";
root.CodePathWin = "sampleplugin.exe";
root.OS = [new ManifestOS { Platform = "windows", MinimumVersion = "10" }];
root.Software = new ManifestSoftware { MinimumVersion = "6.4" };
root.SDKVersion = 2;
root.ApplicationsToMonitor = userConfig.ApplicationsToMonitor;
root.Profiles = userConfig.Profiles;
{
var action = new ManifestAction();
action.Name = "SDTools Test";
action.Uuid = "com.cmpnnt.streamdecktoolkit.sampleplugin.pluginaction";
action.Icon = "Images/pluginAction";
action.PropertyInspectorPath = "PropertyInspector/PluginAction.html";
action.Controllers = ["Keypad"];
action.Tooltip = "Sample action demonstrating SDTools";
action.SupportedInMultiActions = true;
action.States = userConfig.DefaultStates != null
? [..userConfig.DefaultStates]
: [new ManifestStateConfig { Image = "Images/pluginAction" }];
root.Actions.Add(action);
}
// ... one block per action class
return root;
}
}
}-
File:
SettingsPopulatorGenerator.cs -
Generated files:
{ClassName}.PopulateFromJson.g.cs(one per settings class)
Plugin actions have settings classes whose values arrive from the Stream Deck software as JsonElement payloads.
These payloads may contain only the properties that changed — a JSON merge/patch, not a full replacement.
The previous solution used reflection and JsonSerializer.Deserialize, which are incompatible with native AOT.
flowchart TD
A["CreateSyntaxProvider:\nfind classes with attributes"] --> B{"Has [SdSettings]\nattribute?"}
B -- yes --> C["Inspect public writable\nproperties"]
C --> D["For each property:\n- Read [JsonPropertyName] or camelCase\n- Read [FilenameProperty]\n- Classify type"]
D --> E{"Primitive type?"}
E -- yes --> F["Emit JsonElement.GetXxx()"]
E -- no --> G{"Enum?"}
G -- yes --> H["Emit Enum.TryParse / cast"]
G -- no --> I{"Found in a\nJsonSerializerContext?"}
I -- yes --> J["Emit prop.Value.Deserialize(\nContext.Default.TypeName)"]
I -- no --> K["Report SD001 error"]
F & H & J --> L["Emit partial class with\nPopulateFromJson method"]
- Finds classes decorated with
[SdSettings](Cmpnnt.SdTools.Attributes.SdSettingsAttribute). - For each class, collects its public settable properties and determines:
-
JSON key: from
[JsonPropertyName("...")]if present, otherwiseJsonNamingPolicy.CamelCase.ConvertName(PropertyName). -
Is filename: whether
[FilenameProperty]is applied (triggersC:\fakepath\stripping). - Type classification: one of 20+ categories (String, Bool, NullableBool, Int, NullableInt, ..., Enum, NullableEnum, Complex, Unknown).
-
JSON key: from
- For complex types, the generator scans the compilation for
JsonSerializerContextsubclasses with[JsonSerializable(typeof(T))]attributes, building a lookup from type to context expression (e.g.SamplePluginSerializerContext.Default.PluginAction2Settings). - Emits a
partial classimplementingISettingsPopulatablewith aPopulateFromJson(JsonElement)method. The method iterates the JSON properties and dispatches via aswitch (prop.Name)— no reflection, no runtime Type lookups.
| Type | Generated accessor | JSON ValueKind check |
|---|---|---|
string |
GetString() |
String || Null |
bool |
GetBoolean() |
True || False |
int |
GetInt32() |
Number |
long |
GetInt64() |
Number |
float |
GetSingle() |
Number |
double |
GetDouble() |
Number |
decimal |
GetDecimal() |
Number |
Guid |
GetGuid() |
String |
DateTime |
GetDateTime() |
String |
| Nullable<T> | Same as T + null branch |
Null or T's kind |
| Enum |
Enum.TryParse / (T)GetInt32()
|
String or Number
|
| Complex | Deserialize(Context.Default.T) |
any |
[FilenameProperty] |
GetString() + fakepath strip |
String |
For PluginAction2Settings with [FilenameProperty] string OutputFileName and string InputString:
// PluginAction2Settings.PopulateFromJson.g.cs
internal partial class PluginAction2Settings : ISettingsPopulatable
{
public int PopulateFromJson(JsonElement element)
{
if (element.ValueKind != JsonValueKind.Object) return 0;
int count = 0;
foreach (JsonProperty prop in element.EnumerateObject())
{
switch (prop.Name)
{
case "outputFileName":
if (prop.Value.ValueKind == JsonValueKind.String)
{
string _raw = prop.Value.GetString();
if (_raw != null)
{
OutputFileName = _raw == "No file..."
? string.Empty
: Uri.UnescapeDataString(_raw.Replace("C:\\fakepath\\", ""));
count++;
}
}
break;
case "inputString":
if (prop.Value.ValueKind == JsonValueKind.String || ...)
{
InputString = prop.Value.GetString();
count++;
}
break;
}
}
return count;
}
}Settings classes must be partial and decorated with [SdSettings]:
[SdSettings]
internal partial class PluginAction2Settings
{
[FilenameProperty]
[JsonPropertyName("outputFileName")]
public string OutputFileName { get; set; }
[JsonPropertyName("inputString")]
public string InputString { get; set; }
}Plugin actions call PopulateFromJson when new settings arrive from the property inspector:
public override void ReceivedSettings(ReceivedSettingsPayload payload)
{
settings.PopulateFromJson(payload.Settings);
SaveSettings();
}| ID | Severity | Description |
|---|---|---|
| SD001 | Error | A property has a complex type that isn't registered in any JsonSerializerContext. Fix: add [JsonSerializable(typeof(YourType))] to your context class. |
-
File:
Sdpi/SdpiGenerator.cs -
Models:
Sdpi/Models/(one per component type) -
Templates:
Sdpi/Templates/(one per component type) -
Helpers:
Sdpi/Utils/PropertyExtractor.cs,StringUtils.cs -
Generated file:
GeneratedSdpiComponents.g.cs
Each Stream Deck plugin action needs a Property Inspector — an HTML page displayed in the Stream Deck software when the user configures an action. Writing and maintaining these HTML pages by hand, especially keeping them in sync with the plugin's settings, is a nightmare. The SDPI (Stream Deck Property Inspector) component library provides web components (<sdpi-textarea>, <sdpi-select>, etc.) but the HTML boilerplate is still manual.
flowchart TD
A["ForAttributeWithMetadataName:\nfind classes with\n[SdpiOutputDirectory]"] --> B["Scan class body for\nnew TextArea(), new Select(),\nnew Checkbox(), etc."]
B --> C["PropertyExtractor:\nparse object initializer\nsyntax tree"]
C --> D["Build Model\n(TextAreaModel, SelectModel, etc.)"]
D --> E["Template.GenerateComponent(model)\n→ HTML string"]
E --> F["HtmlTemplates.GenerateHtmlDocument:\nwrap in HTML skeleton"]
F --> G["Emit C# file containing\nHTML as string constants"]
G --> H["MSBuild ExtractSdpiHtml task:\nextract HTML to output directory"]
The SDPI generator has the most complex pipeline of the four generators because it bridges C# code to HTML output via a build task:
-
Discovery: Uses
ForAttributeWithMetadataNameto find classes decorated with[SdpiOutputDirectory("PropertyInspector/")]. -
Component extraction: Walks the class syntax tree looking for
BaseObjectCreationExpressionSyntaxnodes. For each, resolves the type symbol and checks if it's a known SDPI component (Cmpnnt.SdTools.Components.TextArea,.Select,.Checkbox, etc.). -
Property extraction (
PropertyExtractor): Parses the object initializer to extract property values at compile time. Handles string literals, integer constants, boolean values, nested objects (e.g.DataSourceSettings), and collection expressions (e.g. option lists). -
Model population: Each component type has a model class (e.g.
TextAreaModelwithLabel,Setting,Rows,MaxLength,ShowLength, etc.). The generator populates the model from the extracted property dictionary. -
HTML generation: Each component type has a template (e.g.
TextAreaTemplate.GenerateComponent(model)) that emits the corresponding HTML using the Elgato SDPI web component syntax. Properties are converted to kebab-case HTML attributes viaStringUtils.ToKebabCase(). -
HTML document wrapping: All component HTML fragments for a class are combined into a full HTML document with the SDPI boilerplate (
<script src="sdpi-components.js">, etc.). If the class contains anyGroupStartfields, a<style>block with matching SDPI styles is injected into<head>. -
C# output: The generator emits a
GeneratedSdpiComponents.g.csfile containing the HTML as C# string constants, organized by class name withOutputPathmetadata. -
Build task extraction: A separate MSBuild task (
ExtractSdpiHtml) reads the generated.g.csfile afterCoreCompile, extracts the HTML strings, and writes them to disk as.htmlfiles in the plugin's output directory.
| Component class | HTML element | Key properties |
|---|---|---|
TextArea |
<sdpi-textarea> |
Setting, Rows, MaxLength, ShowLength, Placeholder, Required, Readonly |
Textfield |
<sdpi-textfield> |
Setting, Placeholder, Pattern, MaxLength, Required, Readonly |
Checkbox |
<sdpi-checkbox> |
Setting |
CheckboxList |
<sdpi-checkbox-list> |
Setting, Columns, Options |
Button |
<sdpi-button> |
Value |
Date/DateTime/Month/Time/Week |
<sdpi-calendar> |
Setting, Type, Min, Max, Step |
Color |
<sdpi-color> |
Setting |
Delegate |
custom | Setting, Invoke, FormatType |
File |
<sdpi-file> |
Setting, Accept |
Password |
<sdpi-password> |
Setting, Placeholder, MaxLength, Required |
Radio |
<sdpi-radio> |
Setting, Columns, Options |
Range |
<sdpi-range> |
Setting, Min, Max, Step, ShowLabels |
Select |
<sdpi-select> |
Setting (via PersistenceSettings), Placeholder, Options |
GroupStart |
<details class="sdpi-group"> |
Label, Open |
GroupEnd |
</details> |
— |
All components except GroupStart/GroupEnd share Label (wraps in <sdpi-item>) and Disabled.
Declare SDPI components as fields in a plugin action class decorated with [SdpiOutputDirectory]. Fields are emitted in declaration order.
// PluginAction.cs
[SdpiOutputDirectory("PropertyInspector/")]
public partial class PluginAction : KeyAndEncoderBase
{
public TextArea ta = new()
{
MaxLength = 250,
Rows = 3,
Label = "Textarea",
ShowLength = true,
Setting = "short_description"
};
public Select cbl = new()
{
Label = "Select List",
Setting = "fav_numbers",
DataSourceSettings = new DataSourceSettings()
{
Options = [
new OptionSetting { Value = "1", Label = "One" },
new OptionSetting { Value = "2", Label = "Two" },
]
}
};
}Wrap any number of components between a GroupStart and GroupEnd field to produce a collapsible <details> section. Multiple groups per class are supported. Set Open = true on GroupStart to render the group expanded by default.
public GroupStart AdvancedGroup = new() { Label = "Advanced Settings" };
public Checkbox VerboseLogging = new()
{
Label = "Verbose Logging",
PersistenceSettings = new() { Global = true, Setting = "VerboseLogging" }
};
public GroupEnd AdvancedGroupEnd = new();When any GroupStart is present the generator injects a <style> block into <head> that matches the SDPI component library's visual style (gray summary text, animated ▶ chevron, correct font stack).
The generator produces HTML that ends up at PropertyInspector/PluginAction.html:
<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="utf-q8" />
<script src="sdpi-components.js"></script>
<style>
details.sdpi-group { margin: 0 0 10px 0; }
details.sdpi-group > summary { color: #969696; ... }
details.sdpi-group > summary::before { content: '▶'; ... }
details.sdpi-group[open] > summary::before { transform: rotate(90deg); }
</style>
</head>
<body>
<sdpi-item label="Textarea">
<sdpi-textarea setting="short_description" rows="3"
maxlength="250" showlength></sdpi-textarea>
</sdpi-item>
<sdpi-item label="Select List">
<sdpi-select>
<option value="1">One</option>
<option value="2">Two</option>
</sdpi-select>
</sdpi-item>
<details class="sdpi-group">
<summary>Advanced Settings</summary>
<sdpi-item label="Verbose Logging">
<sdpi-checkbox global setting="VerboseLogging" label="Verbose Logging"></sdpi-checkbox>
</sdpi-item>
</details>
</body>
</html>sequenceDiagram
participant SD as Stream Deck Software
participant PC as PluginContainer
participant REG as PluginActionIdRegistry<br>(generated)
participant PA as PluginAction instance
participant SET as Settings class<br>(generated PopulateFromJson)
participant PI as Property Inspector<br>(generated HTML)
SD->>PC: WebSocket: willAppear (actionId, context, settings)
PC->>REG: CreateAction(actionId, connection, payload)
REG->>PA: new PluginAction(connection, payload)
PA->>SET: Deserialize initial settings
SD->>PI: Load PropertyInspector/PluginAction.html
Note over PI: HTML generated by SdpiGenerator
PI->>SD: User changes a setting
SD->>PC: WebSocket: didReceiveSettings
PC->>PA: ReceivedSettings(payload)
PA->>SET: settings.PopulateFromJson(payload.Settings)
Note over SET: Generated switch/case,<br>no reflection
If you need to create a generator:
- Create a new
.csfile inCmpnnt.SdTools.SourceGenerators/with a class implementingIIncrementalGeneratorand the[Generator]attribute. - The generator is automatically discovered by Roslyn — no registration needed.
- If your generator defines any
DiagnosticDescriptor, add the rule toAnalyzerReleases.Unshipped.mdto satisfy the RS2008 release tracking requirement (enforced byEnforceExtendedAnalyzerRules). - Use
context.SyntaxProvider.ForAttributeWithMetadataName(...)when scanning for a specific attribute, orCreateSyntaxProvider(...)for broader pattern matching. - Generated files appear in the consumer's
obj/Generated/directory under a folder named after your generator's fully-qualified class name.
-
Microsoft.CodeAnalysis4.11.0 — Roslyn APIs -
System.Text.Json9.0.4 — available for generator-internal logic (e.g.JsonNamingPolicy.CamelCase.ConvertName()) but not exposed to consumer projects