Skip to main content

Source generator

Arlecchino.Generators ships inside the Arlecchino package as analyzers/dotnet/cs and holds five incremental generators. They write one file each into the project that references the package: ArlecchinoViewNavigation.g.cs for the routes and the view factory, ArlecchinoStoreRegistration.g.cs for the stores, ArlecchinoCommandRegistration.g.cs for the application commands, and ArlecchinoWidgetRegistration.g.cs for the widgets. All four land in the same namespace. The fifth reads files rather than code and is covered under Localization.

What it looks for

Views — every class declaration with a base list, whose symbol is non-abstract and implements Arlecchino.Navigation.IArlecchinoView. The route name is the type name with a trailing View stripped: ModsView becomes Mods, Settings stays Settings.

Stores — the same, for Arlecchino.Atoms.IArlecchinoStore. The name means nothing here; the marker is the whole declaration. See Stores below.

Commands — the same again, for Arlecchino.Commands.IArlecchinoCommand. See Commands below.

A type nested inside another is found as readily as one at the top level and is named through its owner — Screens.ModsView. One nested privately is skipped instead: the generated file lives in the same assembly but outside that type, so naming it would not compile.

Duplicate route names collapse to the first declaration seen. Routes are emitted with Default first, then the rest ordered ordinally.

What it emits

public static class ViewKind
{
public static ViewRoute None => ViewRoute.None;
public static readonly ViewRoute Default = new ViewRoute("Default");
public static readonly ViewRoute About = new ViewRoute("About");
}

public sealed class GeneratedViewFactory : IArlecchinoViewFactory
{
public bool TryCreate(
IServiceProvider services,
ViewRoute route,
[NotNullWhen(true)] out IArlecchinoView? view) { ... }
}

public static class GeneratedViewRegistration
{
public static ArlecchinoBuilder AddGeneratedViews(this ArlecchinoBuilder builder) { ... }
}

The factory switches on route.Name and news each view up directly. Constructor arguments come from services.GetRequiredService<T>() — the scope the resolver opened for that screen — using the public constructor with the most parameters, so a view is built without reflection and stays AOT-friendly. Namespaces of the views and of those parameter types are emitted as using directives, so views may sit anywhere in the project.

The three types are emitted whether or not the project holds a view yet. A project with none gets an empty ViewKind, a factory that creates nothing and a working AddGeneratedViews(), along with ARL004 — so the first thing a new application sees is a missing route rather than a missing method.

Turning it on

AddGeneratedViews() is an extension on ArlecchinoBuilder, so it sits in the same chain as the rest of the setup:

builder.Services
.AddArlecchino()
.AddGeneratedViews()
.StartAt(ViewKind.Default);

Without that call the generated factory is not registered and only explicit AddView registrations resolve — see Views and navigation.

Stores

A store is a class of atoms that outlives the screens reading it. Marking it with IArlecchinoStore is the whole registration:

public sealed class SettingsStore : IArlecchinoStore
{
public Atom<string> Profile { get; } = new TrackedAtom<string>("");
}
builder.Services
.AddArlecchino()
.AddGeneratedViews()
.AddGeneratedStores()
.StartAt(ViewKind.Default);

AddGeneratedStores() registers every store it found, in the container, as a singleton — no AddSingleton<SettingsStore>() line to forget when a store is added, and no list to keep in sync. Views and commands then take the store as a constructor parameter like any other service.

public static class GeneratedStoreRegistration
{
public static ArlecchinoBuilder AddGeneratedStores(this ArlecchinoBuilder builder)
{
builder.Services.AddSingleton(static services => new SettingsStore());
builder.Services.AddScoped(static services =>
new DraftStore(services.GetRequiredService<ArlecchinoState>()));
return builder;
}
}

Each registration is a factory calling the public constructor with the most parameters, so nothing is built by reflection and trimming keeps working — the same deal the view factory gets.

IArlecchinoScopedStore is the second marker: a store that belongs to one screen rather than to the application. It is registered AddScoped, so it is built inside the scope the resolver opens per screen, disposed with it, and built afresh when the screen is opened again. IArlecchinoScopedStore extends IArlecchinoStore, so it is found the same way.

MarkerLifetimeHolds
IArlecchinoStoreSingletonState the whole application shares: settings, the catalogue, the session
IArlecchinoScopedStoreScoped to the screenState one screen owns but keeps out of the view: an editor's draft, a wizard's answers

Nothing forces a store to be one or the other; a class with neither marker is simply invisible to the generator and can still be registered by hand.

Widgets

.AddGeneratedWidgets() does the same for the widgets of the project — every class implementing IArlecchinoWidget, registered as a singleton built by a factory:

builder.Services
.AddArlecchino()
.AddGeneratedViews()
.AddGeneratedStores()
.AddGeneratedWidgets()
.StartAt(ViewKind.Default);

Only widgets declared in your own project are registered — the built-in ones live in the package's assembly, which the generator never looks at.

A singleton widget is one instance for the life of the application, and a widget holds state: the selection, the scroll offset, whether it has the focus. Two screens resolving the same widget share all of it. That is the point when the widget is a shared panel, and a bug when it is not — build the second kind in the view instead, as before.

Some widgets cannot be registered at all, and the generator says so with ARL007 rather than emitting code that would not compile:

Left out whenBecause
The class is genericThere is no single closed type to register
It has no public constructorThe factory has nothing to call
It has required membersA factory cannot fill them in — ListBox<T>.Render is exactly this
It is nested privately in another typeThe generated file cannot name it, and code that names it would not compile

The three built-in reasons cover the built-in widgets too, which is another way of saying the same thing: ListBox<T>, Table<T> and Form are constructed where they are used, with their Render, Columns and Fields given at the call site.

ArlecchinoGenerateWidgets set to false turns the generator off.

AddWidget<T>() registers one widget by hand, the same singleton the generator would have made. It is for a widget the generator cannot see — one from another assembly — and it is an alternative to AddGeneratedWidgets(), not a layer on top: registering the same type both ways puts it in the container twice, exactly as AddCommand<T>() and AddGeneratedCommands() do.

builder.Services
.AddArlecchino()
.AddWidget<SearchPanel>()
.StartAt(ViewKind.Default);

Commands

An application command is a class implementing IArlecchinoCommand, and it registers the way a store does:

public sealed class QuitCommand : IArlecchinoCommand
{
private readonly IHostApplicationLifetime _lifetime;

public QuitCommand(IHostApplicationLifetime lifetime) => _lifetime = lifetime;

public KeyBinding Binding => new(ConsoleKey.Q, control: true);
public string Icon => "×";
public string Label => "Quit";

public ViewRoute Execute()
{
_lifetime.StopApplication();
return ViewRoute.None;
}
}
builder.Services
.AddArlecchino()
.AddGeneratedViews()
.AddGeneratedStores()
.AddGeneratedCommands()
.StartAt(ViewKind.Default);
public static class GeneratedCommandRegistration
{
public static ArlecchinoBuilder AddGeneratedCommands(this ArlecchinoBuilder builder)
{
builder.Services.AddSingleton<IArlecchinoCommand>(static services =>
new QuitCommand(services.GetRequiredService<IHostApplicationLifetime>()));
return builder;
}
}

Every command becomes a singleton IArlecchinoCommand built from its public constructor with the most parameters, so CommandRegistry and the palette pick it up with no list to keep in sync.

AddGeneratedCommands() and AddCommand<T>() are alternatives, not layers: calling both for the same type registers it twice and it appears twice in the palette. Use the generator, and keep AddCommand<T>() for a command that comes from another assembly or is chosen at runtime.

Screen commands are a different thing — a view returns those from Commands() as data and nothing registers them. See Commands.

MSBuild switches

The package's build/Arlecchino.props marks these properties compiler-visible; set them in your csproj.

PropertyEffect
ArlecchinoViewNamespaceNamespace ViewKind, GeneratedViewFactory, AddGeneratedViews, AddGeneratedStores and AddGeneratedCommands land in
RootNamespaceFallback when ArlecchinoViewNamespace is unset: $(RootNamespace).Navigation, or Views if that is empty too
ArlecchinoGenerateViewsSet to false to emit no routes and no view factory
ArlecchinoGenerateStoresSet to false to emit no store registration
ArlecchinoGenerateWidgetsSet to false to emit no widget registration
ArlecchinoGenerateCommandsSet to false to emit no command registration
ArlecchinoLocalizationFolderWhich folder holds the localization files; Localization unless set
ArlecchinoLocalizationLanguageWhich of them is the default the rest translate; en unless set
<PropertyGroup>
<ArlecchinoViewNamespace>MyApp.Views</ArlecchinoViewNamespace>
</PropertyGroup>

Whichever namespace it lands in, files that navigate have to import it — using MyApp.Navigation; by default — and that is what makes ViewKind.Mods read like an enum at the call site. Views may live in that namespace or anywhere else; the generated file imports what it needs either way.

Diagnostics

The generator says something instead of quietly doing the wrong thing:

IdSeverityMeans
ARL001WarningTwo views produce the same route — Sample.ModsView and Sample.Extra.ModsView both become Mods. The first one wins and the other is unreachable; rename one of them or register it explicitly
ARL002WarningA view implements IArlecchinoView but has no public constructor, so the generated factory cannot create it and leaves it out
ARL003InfoArlecchinoViewNamespace is not set, so ViewKind lands in $(RootNamespace).Navigation — the message names the namespace it chose
ARL004InfoNo class implements IArlecchinoView, so ViewKind holds no routes and the application has nowhere to start
ARL005WarningA store implements IArlecchinoStore but has no public constructor, so it is left out of AddGeneratedStores()
ARL006WarningA command implements IArlecchinoCommand but has no public constructor, so it is left out of AddGeneratedCommands()
ARL007InfoA widget cannot be registered — generic, no public constructor, or required members — and is left out of AddGeneratedWidgets()
ARL021ErrorA localization file could not be read — the message says what is wrong with it
ARL022ErrorNo localization file says it is the default; one must set language to ArlecchinoLocalizationLanguage
ARL023ErrorA translation has a string the default does not, so nothing would ever ask for it
ARL024InfoA translation is missing a string; the default is drawn there instead

Whether a constructor parameter is actually registered in the container is not something the generator can see; that surfaces at startup as the usual InvalidOperationException from the provider.