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.
| Kind | Runs at | Reads | Produces |
|---|---|---|---|
| Derive | after signatures are registered | a frozen TypeInfo | members on the type that requested it |
| Analyzer | declaration or body phase | a frozen UnitView | findings, and source |
| Template | parse | the raw text of a delimited region | E# 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.
Acquisition
Section titled “Acquisition”There are two tiers, and the difference is when the code is available, not what it is allowed to do.
| Tier | How | Available | May contain |
|---|---|---|---|
| Pack | @(EsharpProviderPack) in the .esproj — a compiled assembly | before the first token is scanned | derives, analyzers, templates |
| Project | comptime source in the project itself | after the compilation that defines it | derives, 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 (ES2920–ES2922); 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.
Derives
Section titled “Derives”@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.
Analyzers
Section titled “Analyzers”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.
Analyzer phases
Section titled “Analyzer phases”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.
| Phase | Sees | May emit |
|---|---|---|
Declaration | types and function signatures; no call sites | yes — the emitted source joins this compilation |
Body (default) | call sites, with the function each is inside | no (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.
Findings
Section titled “Findings”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.
Generated source
Section titled “Generated source”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.
| Rule | Diagnostic |
|---|---|
| A unit shall have a name; one provider shall not emit two units of one name | ES2931 |
| Emission is a declaration-phase capability | ES2932 |
| A path shall be relative and shall not escape the project | ES2933 |
| Two providers shall not emit to one path | ES2936 |
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.
Delimited regions
Section titled “Delimited regions”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 templateRegionBody = "{" { 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.
Where it ends
Section titled “Where it ends”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.
Position
Section titled “Position”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.
| Position | Written | Shall produce |
|---|---|---|
| Declaration | at namespace scope | any number of declarations |
| Statement | inside a function body | any number of statements, spliced into the enclosing block |
| Value | anywhere an expression is expected | exactly 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.
The target
Section titled “The target”@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.
Borrowing E#
Section titled “Borrowing E#”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.
Classification
Section titled “Classification”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.
The frozen projection
Section titled “The frozen projection”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.
Isolation
Section titled “Isolation”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.
Diagnostics
Section titled “Diagnostics”Comptime reports in the ES29xx range: ES290x–ES291x 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.