Skip to content

Compile-time providers

A provider is code that runs inside the compiler while your program is being compiled. E# has three kinds, and they are one mechanism rather than three: each declares a name, each is discovered out of the same assembly, and each is reached through the same registry.

KindRuns atReadsProduces
Deriveafter signatures are registereda frozen TypeInfomembers on the type that requested it
Analyzerdeclaration or body phasea frozen UnitViewfindings, and source
Templateparsethe raw text of a delimited regionE# syntax spliced in place

E# has no built-in providers. A standard pack ships alongside the compiler, but nothing references it implicitly: it is acquired the same way anyone else’s is, by naming it. With no pack referenced, no name is claimed and none of what follows is reachable.

Everything a provider produces is additive. No provider rewrites a declaration somebody wrote: a hand-written member of the same name and arity always wins, silently, and that is what lets a declaration mean what it says regardless of which packs a project references.

There are two tiers, and the difference is when the code is available, not what it is allowed to do.

TierHowAvailableMay contain
Pack@(EsharpProviderPack) in the .esproj — a compiled assemblybefore the first token is scannedderives, analyzers, templates
Projectcomptime source in the project itselfafter the compilation that defines itderives, templates

A pack is deliberately not an ordinary @(Reference). A reference is something the program compiles against; a pack is something the compiler executes while compiling it. Conflating them would make “run this at build time” an invisible property of an ordinary dependency.

<ItemGroup>
<EsharpProviderPack Include="../MyRules/bin/Release/net10.0/MyRules.dll" />
</ItemGroup>

A pack that does not exist, cannot be loaded, or holds a provider that will not construct is reported per pack and does not stop the build (ES2920ES2922); the providers that did load still run.

Reach follows acquisition. A template from a pack is loaded before lexing and can own a region in any file. A template compiled from the project’s own comptime source is compiled by the same compilation that consumes it, so it reaches only what the staging order can place after it — fine for a small format of your own, and the reason anything amounting to a compiler inside the compiler belongs in a pack.

An analyzer is pack-only, by mechanism rather than policy: it runs over a bound program, and one acquired from the project’s own source would run over the program that defines it. That is a fixpoint, not a pass.

A provider’s name is declared, never inferred from the type that implements it. A type Foo is not automatically the derive Foo; it is the derive it says it is:

@derive(foobar)

Discovery reads the declared name and only the declared name — there is no type-name fallback, so renaming the implementing type never silently renames the thing use sites spell.

Names are unique across all three kinds, because a use site cannot disambiguate: @derive(x) and @x { … } both name x, and only one provider may answer to it. Two packs claiming one name is ES2923, reported against the second — the first is the incumbent, the second is the change.

@derive(...) precedes a declaration — a struct, class, interface, union, enum, a field or property, a method or free function — and synthesizes members at compile time. The grammar and the built-in traits are in Declarations → derive. Three rules belong here:

A derive is told what it was attached to. It reads the enclosing type’s frozen projection and the site: which declaration carried the directive, and that declaration’s own shape. A derive on a field is not a derive on the type that happens to know a field’s name — the site is part of the request.

What it produces lands on the enclosing declaration, whatever the site: a derive attached to a field adds members to the type declaring that field. The annotated declaration itself is never rewritten — it still means exactly what it says.

The trait set is closed by default and opened by reference. With no pack, the recognized set is exactly the built-ins and an unrecognized name is ES2242. A referenced pack adds its own names to that set; the diagnostic then fires only for a name no built-in and no loaded provider claims.

Arguments configure a derive; the outer list selects it. @derive(record, Json(naming: "snake")) selects two derives and configures the second. An argument shall be a constant expression that folds to a literal (ES2246); a provider receives the folded value, never syntax.

An analyzer reads one compilation unit and reports on it — and, when it has something to contribute rather than merely something to say, emits source alongside.

Reporting and emitting are the same kind. Splitting them into analyzer-versus-generator forces an author who wants to flag a shape and supply the fix to write two providers that cannot see each other’s conclusions.

An analyzer declares which moments it wants and is called once per phase per unit. The phase decides what exists to be looked at, and therefore what may be done about it.

PhaseSeesMay emit
Declarationtypes and function signatures; no call sitesyes — the emitted source joins this compilation
Body (default)call sites, with the function each is insideno (ES2932)

Declaration is the last moment whose output can still be registered and bound with everything else, so an emitted unit is a peer of hand-written source: hand-written code may call it, and it may call hand-written code. After the bind, emitted source could only be appended — visible to nothing that preceded it — so emission there is refused rather than half-honoured.

A rule that wants both asks for both. That is one provider holding one set of conclusions.

A finding carries an id, a span, a message, and a severity. The id is required: a finding with no stable identity cannot be suppressed, cannot be raised or lowered by project policy, cannot be documented, and cannot be referred to in a review. It is the author’s to keep stable — the message may be rewritten freely, the id may not.

ES followed by a digit is the compiler’s own space and is refused (ES2934): a rule that could mint ES2911 could impersonate a compiler diagnostic, and a reader would have no way to tell which one was lying. The rule stops at the digits — ESP0001 is an ordinary id. A finding whose span points outside the unit it was produced for is likewise refused (ES2935).

Severity is what the rule asks for; whether a warning fails the build is the project’s policy.

An emitted unit carries a name and a path chosen by its author, not an opaque hint name. That single decision is what lets provenance, go-to-definition, and span-owned eviction work on generated code exactly as they work on written code — a generated unit is a unit.

RuleDiagnostic
A unit shall have a name; one provider shall not emit two units of one nameES2931
Emission is a declaration-phase capabilityES2932
A path shall be relative and shall not escape the projectES2933
Two providers shall not emit to one pathES2936

A path left unset defaults to <provider>/<name>.es — a default the author can still see and navigate to. Generated source is not analyzed: an analyzer that could read its own output would have to be re-run until it stopped emitting.

A delimited region is a block of text the compiler has no grammar for, handed to the template that claims its sigil. The template supplies the parser; the host supplies binding, spans, and everything downstream.

The sigils below stand for whatever a referenced pack claims. E# defines none of its own.

Region = "@" sigil [ "(" RegionTarget ")" ] RegionBody .
sigil = identifier . // claimed by a loaded template
RegionBody = "{" { any-char | RegionBody } "}" .
@doc {
<page title={source.title}>
<body>{source.text}</body>
</page>
}

Nothing in that body is E# except what the template chose to borrow — here the two { … } holes. The compiler knows the frame and nothing about the contents.

The lexer counts braces and nothing else. It does not know what a string or a comment is in this grammar, so treating " as a quote would corrupt any template where it is not one. Brace counting is the one rule that can be stated without knowing the grammar, and it is the rule a template author writes against. An unbalanced region is ES2940.

A { opens a region body only immediately after @ and a claimed sigil (with an optional target group between). @nope { … } where nothing claims nope is not a region at all — the braces lex as an ordinary block, and the source fails as ordinary E#, which is the right answer: the file names a template the project does not have.

The same construct in three positions is three things, and the position is the only thing that says which. A template declares which positions it accepts; writing it elsewhere is ES2946 at the region, rather than an empty declaration list far downstream.

PositionWrittenShall produce
Declarationat namespace scopeany number of declarations
Statementinside a function bodyany number of statements, spliced into the enclosing block
Valueanywhere an expression is expectedexactly one expression (ES2944)

Statement regions splice; they are not wrapped in a nested block. A name a template binds is therefore in scope for the statements that follow it.

A template never chooses its position by inspecting a target type: there is no type information at lex time. This is also why a template is selected by its sigil and can never be selected by what the region flows into.

@t(builder) { … } names a value the region lowers into. It reaches the template as text, not as a parsed expression: a template lowers into it by name, and a syntax tree is not something it has the vocabulary to place.

A template owns its grammar and borrows E#‘s. It asks the host to parse an expression at a point in its own text — the {source.title} hole above — or a list of member declarations, and receives an opaque fragment it positions. It cannot construct E# syntax itself.

That asymmetry is what keeps a compiler-inside-the-compiler from becoming a fork of the compiler. The borrowed regions are the host’s own syntax, parsed by the host, so they bind in the author’s scope, against the author’s names, with the author’s spans — a diagnostic inside a region lands at a real line and column in the file, not as a compiler error about code nobody wrote.

Nothing after the parse learns that a region was written: what reaches the binder is ordinary syntax.

A template also classifies its own text for the editor, separately from parsing — because classification runs on the editor’s schedule and must survive text that does not parse. A half-typed element still needs colours. A template that parses but does not classify compiles fine and is unusable: the region becomes a black box the author edits blind.

Every projection a provider reads — TypeInfo for a derive, UnitView for an analyzer — is built before any provider runs.

That is what makes provider order non-semantic. A provider cannot observe another’s output, so admission order cannot leak into the program, so referencing a pack cannot silently change what a type in an unrelated file derives.

Ordering that is genuinely wanted is declared. A provider names the providers it depends on and the compiler resolves a topological order over them, breaking ties by name so two runs over the same sources admit the same members in the same order. A cycle is ES2911 — a diagnostic, never a hang.

At the analyzer site, dependency ordering is inert and says something true rather than nothing: analyzers read a frozen projection and cannot observe one another, so there is nothing to sequence. They run in ordinal name order, which is all determinism needs.

Projections are versioned. They are views of a model that moves, and a provider compiled against an older shape must be able to refuse rather than misread.

Each pack is loaded into its own collectible load context, so a reference-set change can retire it without restarting the process. The contract assembly is deliberately shared — resolved from the default context and never loaded a second time inside a pack — because two copies mean two provider interfaces that are not the same type, and every provider would then fail to load on identity rather than on anything it did.

A provider that throws degrades that provider: the failure is reported against it and the compilation continues. A third-party assembly running inside the compiler must not be able to take the build down, and “the compiler crashed” is the least actionable thing a build can say.

Comptime reports in the ES29xx range: ES290xES291x for derives and synthesized members, ES292x for provider packs, ES293x for analyzers and generated units, and ES294x for regions and templates. Each code, its trigger, and its fix are in the diagnostic reference.