Skip to content

Host the Razor source generator in rzc (opt-in generate/discover)#55422

Open
chsienki wants to merge 5 commits into
mainfrom
chsienki/rzc-source-generator-host
Open

Host the Razor source generator in rzc (opt-in generate/discover)#55422
chsienki wants to merge 5 commits into
mainfrom
chsienki/rzc-source-generator-host

Conversation

@chsienki

@chsienki chsienki commented Jul 23, 2026

Copy link
Copy Markdown
Member

Summary

Modernizes the legacy Razor CLI tool (rzc) so its generate and discover commands can host the Razor source generator in-process instead of calling the Razor engine (RazorProjectEngine) directly. This keeps the pre-.NET 6 Razor build path (rzc, used for .NET Standard / .NET Framework / sub-net6 target frameworks) on the same, actively-maintained code generation as the .NET 6+ source-generator build, rather than a separate engine-invocation path.

The new path is opt-in behind the _RazorToolUseSourceGenerator MSBuild property (default off) — set /p:_RazorToolUseSourceGenerator=true, or the property in a project, to enable it — so this is a no-op for existing builds. It is a reviewable prototype ahead of a later cutover.

What's here (4 commits)

  1. Add source-generator hosting infrastructure to the Razor tool — a reusable host that maps the tool's inputs to the generator's inputs and creates the driver, and an isolated accessor for the generator's internal RazorGeneratorResult host output (reached via the compiler's existing InternalsVisibleTo("rzc") grant).
  2. Route rzc generate and discover through the hosted source generator — both commands drive the generator when enabled; the engine path is untouched when off. --generate-declaration stays on the engine (see below).
  3. Pass reference assemblies to the Razor generate task in source-generator mode — in SG mode the tool discovers tag helpers from the compilation, so SdkRazorGenerate forwards @(RazorReferencePath) as -a.
  4. Toggle the hosted source generator via a CLI parameter and MSBuild propertygenerate and discover take a --use-source-generator switch driven by the _RazorToolUseSourceGenerator property (forwarded by SdkRazorGenerate and SdkRazorTagHelper). A per-invocation argument is the correct shape under the Razor build server, where the long-lived server serves multiple projects and the choice must travel with each request rather than being fixed in the server's environment.

Validation

  • rzc builds clean (0/0); each commit builds green (bisect-safe).
  • discover: the SG manifest is byte-identical to the engine's for the same references, and more robust (no MVC extension plumbing required).
  • generate: modern-but-equivalent output (enhanced #line directives, SHA-1 checksum); the generated C# compiles against real ASP.NET Core references.
  • End-to-end: a net9 Razor Class Library and a net9 Razor Pages web app (both forced onto the legacy path via UseRazorSourceGenerator=false) build on both the engine and SG paths, toggled with /p:_RazorToolUseSourceGenerator=true. The SG-built web app runs and renders pages correctly (HTTP 200, WebApp.Views.dll with the expected RazorCompiledItemAttribute).

Declaration generation (--generate-declaration)

Component compilation on the legacy path runs a declaration pass (rzc generate --generate-declaration) that emits each component's type surface — the class and its [Parameter] members, with no render body — into a throwaway assembly so components can discover one another as tag helpers before full generation.

The source generator produces this declaration internally but never exposes it as a consumable output: the declaration entry point is private, and the declaration text is only added to the in-memory compilation for discovery — it is never emitted as a generated source or host output. So when the SG path is enabled, the declaration pass falls back to the engine while full generation is SG-hosted.

This is fine for now. A declaration is only a type surface and doesn't rely on any newer code-generation features, so for a given Razor language version the engine-produced declaration is equivalent to what the SG would produce — and both use the same Razor compiler version here. Longer term we intend to extend the SG to expose full declaration output so rzc can produce it there too, but that part of the generator is actively changing as part of Project Sonic, so for now we keep the fallback.

Not in this PR / follow-ups

  • Extend the SG (once Project Sonic's declaration/implementation split settles) to expose declaration output, so --generate-declaration is also SG-hosted.
  • Flip the default to the SG path and remove the engine path (cutover) — gated on the declaration pass no longer needing the engine.

Draft

Opening as a draft for design review — not intended to merge as-is.

chsienki and others added 3 commits July 22, 2026 16:53
The rzc generate and discover commands invoke the Razor engine
(RazorProjectEngine) directly, a separate invocation path from the Razor
source generator that compiles Razor for .NET 6 and later. Add the shared
pieces needed to host that source generator in-process instead:

- SourceGeneratorSwitch gates the hosted path behind the
  RAZOR_TOOL_USE_SOURCE_GENERATOR environment variable so it can be
  validated against the engine path before becoming the default.
- RazorSourceGeneratorHost translates the tool's inputs into the generator's
  inputs (analyzer config options, additional texts, and a reference-only
  compilation) and creates the generator driver.
- RazorSourceGeneratorHostOutput isolates the single place that reads the
  generator's internal RazorGeneratorResult host output, reached through the
  compiler's InternalsVisibleTo("rzc") grant and its experimental
  host-output API.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1cef513d-6587-488a-88e3-48059f1a54b2
When RAZOR_TOOL_USE_SOURCE_GENERATOR is set, both commands drive the Razor
source generator rather than the engine:

- generate builds a compilation from the reference assemblies passed via -a,
  runs the generator, and writes each generated document to the output path
  of the source it was produced from, mapping hint name to source path
  through the generator's host output. Declaration-only generation
  (--generate-declaration) stays on the engine path because the generator
  does not expose the intermediate declaration as an emitted source.
- discover runs the generator over the reference assemblies together with a
  synthetic empty view, which the generator requires before it performs
  reference tag helper discovery, and serializes the discovered tag helpers
  to the existing manifest format.

The engine path is used unchanged when the switch is off.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1cef513d-6587-488a-88e3-48059f1a54b2
…tor mode

In source-generator mode the tool discovers tag helpers from the compilation
instead of a precomputed manifest, so the generate task must provide the
project's references. SdkRazorGenerate forwards @(RazorReferencePath) as -a
arguments, gated by UseSourceGenerator so the engine path is unaffected. A
single opt-in property, defaulting from the RAZOR_TOOL_USE_SOURCE_GENERATOR
environment variable, drives both the task's argument emission and the tool
itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1cef513d-6587-488a-88e3-48059f1a54b2
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

chsienki and others added 2 commits July 23, 2026 16:58
…operty

Select the source-generator-hosted path with a --use-source-generator switch on
the generate and discover commands, driven by the _RazorToolUseSourceGenerator
MSBuild property (default false, so the engine path is unchanged).

A per-invocation command-line argument is the correct shape for this toggle: under
the Razor build server the long-lived server process handles requests from multiple
projects, so the choice must travel with each request rather than being fixed in the
server's environment when it starts.

SdkRazorGenerate and SdkRazorTagHelper forward the switch to the tool (and, for
generate, the reference assemblies the source generator needs) when the property is
set; the property can be toggled with /p:_RazorToolUseSourceGenerator=true or in a
project.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1cef513d-6587-488a-88e3-48059f1a54b2
@chsienki
chsienki marked this pull request as ready for review July 24, 2026 05:16
@chsienki
chsienki requested a review from a team as a code owner July 24, 2026 05:16
Copilot AI review requested due to automatic review settings July 24, 2026 05:16
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 1 pipeline(s).
2 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR modernizes the legacy Razor CLI tool (rzc) by adding an opt-in path for generate and discover to host the Razor source generator in-process, aligning pre-.NET6 Razor builds with the .NET6+ source-generator pipeline. It also wires MSBuild tasks/targets to pass reference assemblies needed for compilation-based tag helper discovery when running in this mode.

Changes:

  • Added in-process hosting infrastructure for the Razor source generator, including access to generator “host outputs” (RazorGeneratorResult).
  • Routed rzc generate and rzc discover through the hosted source generator when --use-source-generator is specified (with the engine path preserved as the default).
  • Updated Razor MSBuild tasks/targets to forward --use-source-generator and pass reference assemblies (-a) to enable compilation-based tag helper discovery.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/RazorSdk/Tool/RazorSourceGeneratorHostOutput.cs Isolates access to experimental Roslyn host output API to retrieve RazorGeneratorResult.
src/RazorSdk/Tool/RazorSourceGeneratorHost.cs Provides shared infrastructure for options/additional texts/compilation/driver creation for hosting the SG.
src/RazorSdk/Tool/GenerateCommand.cs Adds --use-source-generator + -a and implements SG-backed generation path.
src/RazorSdk/Tool/DiscoverCommand.cs Adds --use-source-generator and implements SG-backed tag helper discovery path.
src/RazorSdk/Tasks/SdkRazorTagHelper.cs Forwards --use-source-generator to rzc discover.
src/RazorSdk/Tasks/SdkRazorGenerate.cs Forwards --use-source-generator and reference assemblies (-a) to rzc generate.
src/RazorSdk/Targets/Microsoft.NET.Sdk.Razor.CodeGeneration.targets Introduces _RazorToolUseSourceGenerator toggle and wires it into the tag helper + generation tasks.

Comment on lines +228 to +238
var runResult = RazorSourceGeneratorHost.CreateDriver(parseOptions)
.AddAdditionalTexts(additionalTexts)
.WithUpdatedAnalyzerConfigOptions(optionsProvider)
.RunGeneratorsAndUpdateCompilation(compilation, out _, out _)
.GetRunResult().Results.Single();

if (!RazorSourceGeneratorHostOutput.TryGet(runResult, out var razorResult))
{
Error.WriteLine("The Razor source generator did not produce the expected host output.");
return ExitCodeFailure;
}
Comment on lines +295 to +299
private int ExecuteWithSourceGenerator(SourceItem[] sourceItems)
{
var parseOptions = RazorSourceGeneratorHost.CreateParseOptions(GetCSharpLanguageVersion());
var compilation = RazorSourceGeneratorHost.CreateCompilation(Assemblies.Values, Parent.AssemblyReferenceProvider);

Comment on lines +306 to +313
var optionsProvider = RazorSourceGeneratorHost.CreateOptionsProvider(
razorConfiguration: Configuration.Value(),
razorLanguageVersion: Version.Value(),
rootNamespace: RootNamespace.Value(),
supportLocalizedComponentNames: SupportLocalizedComponentNames.HasValue(),
generateMetadataSourceChecksumAttributes: false,
projectDirectory: ProjectDirectory.Value(),
files: inputFiles);
Comment on lines +42 to 46
<!-- Opt-in: route the Razor tool's discover and generate steps through the hosted source
generator. Toggle with /p:_RazorToolUseSourceGenerator=true or a project property; the
tasks forward it to the tool's use-source-generator switch. -->
<_RazorToolUseSourceGenerator Condition="'$(_RazorToolUseSourceGenerator)' == ''">false</_RazorToolUseSourceGenerator>
</PropertyGroup>
Comment on lines +90 to +95
// The source generator path handles ordinary generation. Declaration-only generation
// (--generate-declaration) has no public generator output, so it stays on the engine path.
if (UseSourceGenerator.HasValue() && !GenerateDeclaration.HasValue())
{
return Task.FromResult(ExecuteWithSourceGenerator(sourceItems));
}

@davidwengier davidwengier left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Opening as a draft for design review — not intended to merge as-is.

No you didn't, so I reviewed it 😛

Generally LGTM, though I didn't see a change that I was expecting: Keeping discover and generate commands working individually makes sense (for now), I would have thought there would be a change such that if using the generator, the discover command is not run at all. What am I missing?

{
outputFilePath = Path.Combine(projectDirectory, outputFilePath);

var parseOptions = RazorSourceGeneratorHost.CreateParseOptions(LanguageVersion.Default);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm curious if this is a change, in a scenario where Roslyn DLLs come from .NET 10, but the TFM is .NET 8? This would be C# 14 but perhaps the old way is 12?

@chsienki

Copy link
Copy Markdown
Member Author

Opening as a draft for design review — not intended to merge as-is.

No you didn't, so I reviewed it 😛

Generally LGTM, though I didn't see a change that I was expecting: Keeping discover and generate commands working individually makes sense (for now), I would have thought there would be a change such that if using the generator, the discover command is not run at all. What am I missing?

It's kind of weird in that right now the pipeline will call discover, but the generate command will essentially ignore what it found and just re-discover things as part of the process anyway. So its super inefficient right now, but I wanted to try and keep things as in place as possible.

I suppose we could probably just make discover return nothing in SG mode, I just wanted to keep the break as small as possible. But thinking about it I can't see that what's discovered is used anywhere else, so it's probably fine.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants