Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ argument.
| `Run(IIncrementalGenerator, IReadOnlyDictionary<string, string>?)` | `GeneratorRunResult` | Runs one generator against every file in the directory. An override replaces a file's contents, or adds a file. |
| `RunWithOnly(IIncrementalGenerator, params string[])` | `GeneratorRunResult` | Runs one generator against only the named files, so a test can assert what it does when one is absent. |
| `RunAll(IReadOnlyList<IIncrementalGenerator>, IReadOnlyDictionary<string, string>?)` | `GeneratorDriverRunResult` | Runs several generators and returns the whole driver result. |
| `ReusesCachedOutputOnRerun(IIncrementalGenerator)` | `bool` | Runs the generator twice over identical metadata and reports whether the second run reused the first run's outputs. |
| `ReusesCachedOutputOnRerun(IIncrementalGenerator)` | `bool` | Runs the generator twice over identical metadata, the second time against a new but equivalent compilation as an edit would, and reports whether the second run reused the first run's outputs. A generator with no tracked output steps reports `false`. |

## Contributing

Expand Down
14 changes: 14 additions & 0 deletions SourceGeneratorToolkit.Test/GeneratorBaseTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,18 @@ public void TheGeneratorReusesItsOutputWhenNothingChanged()
Harness.ReusesCachedOutputOnRerun(new ThingsGenerator()),
"An IIncrementalGenerator that recomputes everything on every keystroke still passes every output assertion.");
}

[TestMethod]
public void TheRerunCheckCatchesAGeneratorThatRecomputesOnEveryEdit()
{
Assert.IsFalse(
Harness.ReusesCachedOutputOnRerun(new CompilationBoundGenerator()),
"A generator whose output hangs off the compilation recomputes on every keystroke, and the check must say so.");
}

[TestMethod]
public void TheRerunCheckDoesNotPassAGeneratorWithNoOutputSteps()
{
Assert.IsFalse(Harness.ReusesCachedOutputOnRerun(new SilentGenerator()));
}
}
22 changes: 22 additions & 0 deletions SourceGeneratorToolkit.Test/TestGenerators.cs
Original file line number Diff line number Diff line change
Expand Up @@ -255,3 +255,25 @@ internal sealed class AbsentFileGenerator() : GeneratorBase<ThingsMetadata>("now
protected override void Generate(SourceProductionContext context, ThingsMetadata metadata, CodeBlocker codeBlocker) =>
context.AddSource("Nowhere.g.cs", "// unreachable");
}

/// <summary>
/// Derives its output from the compilation itself, so it recomputes on every edit: the
/// non-incremental generator the rerun check has to catch.
/// </summary>
internal sealed class CompilationBoundGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context) =>
context.RegisterSourceOutput(
context.CompilationProvider.Select((_, _) => new object()),
(productionContext, _) => productionContext.AddSource("x.g.cs", "// x"));
}

/// <summary>
/// Registers no output at all, so the rerun check has nothing to prove incremental.
/// </summary>
internal sealed class SilentGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
}
}
32 changes: 26 additions & 6 deletions SourceGeneratorToolkit.Testing/GeneratorHarness.cs
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,28 @@ public GeneratorDriverRunResult RunAll(
Drive(generators, BuildTexts(overrides));

/// <summary>
/// Runs a generator twice over identical metadata and reports whether the second run reused the
/// first run's cached outputs.
/// Runs a generator twice over identical metadata, the second time against a new but equivalent
/// compilation, and reports whether the second run reused the first run's cached outputs.
/// </summary>
/// <param name="generator">The generator to run.</param>
/// <returns>True when no tracked output step had to be recomputed on the second run.</returns>
/// <returns>
/// True when the generator tracked at least one output step and none of them had to be
/// recomputed on the second run.
/// </returns>
/// <remarks>
/// These are <see cref="IIncrementalGenerator"/>s, and output assertions do not check that they
/// behave like one: a generator that recomputes everything on every keystroke still passes every
/// output assertion, it just makes the IDE slow.
/// <para>
/// A keystroke gives the generator a new <see cref="Compilation"/> while the metadata files are
/// untouched, so that is what the second run gets. Rerunning against the same compilation instance
/// would prove nothing: Roslyn compares incremental inputs by reference, so every step would be
/// reported as cached whatever the generator did with the compilation.
/// </para>
/// <para>
/// A generator with no tracked output steps has nothing to prove incremental, so it is reported
/// as not reusing its output rather than passing by default.
/// </para>
/// </remarks>
public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator)
{
Expand All @@ -112,12 +125,19 @@ public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator)
driverOptions: new GeneratorDriverOptions(IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));

driver = driver.RunGenerators(compilation);
GeneratorDriverRunResult second = driver.RunGenerators(compilation).GetRunResult();

return !second.Results[0].TrackedOutputSteps
// An empty tree changes nothing the generator could observe semantically, but it is a new
// Compilation instance, which is what an edit hands the generator.
CSharpCompilation edited = compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(string.Empty));
GeneratorDriverRunResult second = driver.RunGenerators(edited).GetRunResult();

List<IncrementalStepRunReason> reasons = [.. second.Results[0].TrackedOutputSteps
.SelectMany(pair => pair.Value)
.SelectMany(step => step.Outputs)
.Any(output => output.Reason is not (IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged));
.Select(output => output.Reason)];

return reasons.Count > 0
&& reasons.All(reason => reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged);
}

private List<AdditionalText> BuildTexts(IReadOnlyDictionary<string, string>? overrides)
Expand Down
Loading