diff --git a/README.md b/README.md index e08a9b0..efd9530 100644 --- a/README.md +++ b/README.md @@ -353,7 +353,7 @@ argument. | `Run(IIncrementalGenerator, IReadOnlyDictionary?)` | `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, IReadOnlyDictionary?)` | `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 diff --git a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs index 3e93d7c..cbe8d8e 100644 --- a/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs +++ b/SourceGeneratorToolkit.Test/GeneratorBaseTests.cs @@ -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())); + } } diff --git a/SourceGeneratorToolkit.Test/TestGenerators.cs b/SourceGeneratorToolkit.Test/TestGenerators.cs index c24da3f..28a5977 100644 --- a/SourceGeneratorToolkit.Test/TestGenerators.cs +++ b/SourceGeneratorToolkit.Test/TestGenerators.cs @@ -255,3 +255,25 @@ internal sealed class AbsentFileGenerator() : GeneratorBase("now protected override void Generate(SourceProductionContext context, ThingsMetadata metadata, CodeBlocker codeBlocker) => context.AddSource("Nowhere.g.cs", "// unreachable"); } + +/// +/// Derives its output from the compilation itself, so it recomputes on every edit: the +/// non-incremental generator the rerun check has to catch. +/// +internal sealed class CompilationBoundGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) => + context.RegisterSourceOutput( + context.CompilationProvider.Select((_, _) => new object()), + (productionContext, _) => productionContext.AddSource("x.g.cs", "// x")); +} + +/// +/// Registers no output at all, so the rerun check has nothing to prove incremental. +/// +internal sealed class SilentGenerator : IIncrementalGenerator +{ + public void Initialize(IncrementalGeneratorInitializationContext context) + { + } +} diff --git a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs index cdeb6ba..a05d4cf 100644 --- a/SourceGeneratorToolkit.Testing/GeneratorHarness.cs +++ b/SourceGeneratorToolkit.Testing/GeneratorHarness.cs @@ -89,15 +89,28 @@ public GeneratorDriverRunResult RunAll( Drive(generators, BuildTexts(overrides)); /// - /// 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. /// /// The generator to run. - /// True when no tracked output step had to be recomputed on the second run. + /// + /// True when the generator tracked at least one output step and none of them had to be + /// recomputed on the second run. + /// /// /// These are 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. + /// + /// A keystroke gives the generator a new 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. + /// + /// + /// 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. + /// /// public bool ReusesCachedOutputOnRerun(IIncrementalGenerator generator) { @@ -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 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 BuildTexts(IReadOnlyDictionary? overrides)