From 17112fa2e1384c4db54bd6303f208c58af5f0da5 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 29 Jun 2026 22:53:12 +1200 Subject: [PATCH 1/9] Add net10 Solution.g codegen console + shared emitter (Lever 1 engine) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First half of freeing the Fallout.SourceGenerators dependency cone (issue Fallout-build#441, Lever 1): introduce a net10 pre-build codegen path for the strongly-typed [Solution] accessor as the future default, with the in-compiler generator retained as the toggle fallback. - src/shared/SolutionEmitter.cs: the Scriban emit logic extracted out of StronglyTypedSolutionGenerator into a single shared source file, linked into BOTH the generator and the new console so they emit byte-identical Solution.g.cs. - StronglyTypedSolutionGenerator: refactored to call SolutionEmitter (behaviour unchanged — still the in-IDE/live fallback). - src/Fallout.Solution.Codegen: net10 console that discovers [Solution(GenerateProjects = true)] members by parsing the build project's .cs syntactically (no compilation available pre-build), reads the solution with the real Fallout.Persistence.Solution parser (no parsing-parity risk), and emits via the shared emitter. Verified end-to-end against this repo's own build/Build.cs + fallout.slnx — output matches the generator. - AssemblyInfo: IVT for the console (it uses Build.Shared's internal Constants). Remaining (next commits on this branch): the packaged pre-build MSBuild target + FalloutSolutionCodegenMode toggle (default = console; suppress the generator in console mode), dogfood-build wiring, and — as the deliberate follow-up — dropping the now-removable cone refs from Fallout.SourceGenerators once the fallback is retired/cone-freed. Co-Authored-By: Claude Opus 4.8 (1M context) --- AssemblyInfo.cs | 1 + fallout.slnx | 1 + .../Fallout.Solution.Codegen.csproj | 26 +++++ src/Fallout.Solution.Codegen/Program.cs | 109 ++++++++++++++++++ .../Fallout.SourceGenerators.csproj | 5 + .../StronglyTypedSolutionGenerator.cs | 103 ++--------------- src/shared/SolutionEmitter.cs | 101 ++++++++++++++++ 7 files changed, 251 insertions(+), 95 deletions(-) create mode 100644 src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj create mode 100644 src/Fallout.Solution.Codegen/Program.cs create mode 100644 src/shared/SolutionEmitter.cs diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index df15df355..c7362d976 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -9,6 +9,7 @@ [assembly: InternalsVisibleTo("Fallout.Cli.Specs")] [assembly: InternalsVisibleTo("Fallout.ProjectModel.Specs")] [assembly: InternalsVisibleTo("Fallout.SourceGenerators")] +[assembly: InternalsVisibleTo("Fallout.Solution.Codegen")] [assembly: InternalsVisibleTo("Fallout.Solution")] [assembly: InternalsVisibleTo("Fallout.Solution.Specs")] [assembly: InternalsVisibleTo("Fallout.Persistence.Solution")] diff --git a/fallout.slnx b/fallout.slnx index 808eb0ea0..79fada201 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -27,6 +27,7 @@ + diff --git a/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj b/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj new file mode 100644 index 000000000..f54edf0d6 --- /dev/null +++ b/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj @@ -0,0 +1,26 @@ + + + + Exe + net10.0 + false + Pre-build codegen for the strongly-typed [Solution] accessor (Solution.g.cs). The net10 default that frees the Roslyn generator from the solution-parser dependency cone; reuses the real Fallout.Persistence.Solution parser and the shared SolutionEmitter. + + + + + + + + + + + + + + + + + + + diff --git a/src/Fallout.Solution.Codegen/Program.cs b/src/Fallout.Solution.Codegen/Program.cs new file mode 100644 index 000000000..1fa4580f9 --- /dev/null +++ b/src/Fallout.Solution.Codegen/Program.cs @@ -0,0 +1,109 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json.Nodes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Solutions; + +// Pre-build codegen for the strongly-typed [Solution] accessor. +// +// Usage: +// Fallout.Solution.Codegen --root --out [--source ...] +// +// Discovers [Solution(GenerateProjects = true)] members by parsing the given source files +// syntactically (no compilation available pre-build), resolves each solution file, and writes +// .g.cs into using the shared SolutionEmitter. No-op (exit 0) if none found. + +var root = GetOption("--root") ?? Directory.GetCurrentDirectory(); +var outDir = GetOption("--out") ?? Path.Combine(root, "obj"); +var sources = GetOptions("--source").Where(File.Exists).ToList(); +if (sources.Count == 0) + sources = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories).ToList(); + +var rootDirectory = (AbsolutePath)root; +Directory.CreateDirectory(outDir); + +foreach (var (memberName, relativePath, fancyNames) in DiscoverSolutionMembers(sources)) +{ + var solutionFile = !string.IsNullOrEmpty(relativePath) + ? rootDirectory / relativePath + : GetSolutionFileFromParametersFile(rootDirectory, memberName); + + var solution = solutionFile.ReadSolution(); + File.WriteAllText(Path.Combine(outDir, memberName + ".g.cs"), SolutionEmitter.Emit(solution, memberName, fancyNames)); + Console.WriteLine($"Fallout.Solution.Codegen: generated {memberName}.g.cs from {solutionFile}"); +} + +return 0; + +string GetOption(string name) +{ + var index = Array.IndexOf(args, name); + return index >= 0 && index + 1 < args.Length ? args[index + 1] : null; +} + +IEnumerable GetOptions(string name) +{ + for (var i = 0; i < args.Length - 1; i++) + if (args[i] == name) + yield return args[i + 1]; +} + +static IEnumerable<(string MemberName, string RelativePath, bool FancyNames)> DiscoverSolutionMembers(IEnumerable files) +{ + foreach (var file in files) + { + var root = CSharpSyntaxTree.ParseText(File.ReadAllText(file)).GetRoot(); + foreach (var member in root.DescendantNodes().OfType()) + { + if (member is not (FieldDeclarationSyntax or PropertyDeclarationSyntax)) + continue; + + foreach (var attribute in member.AttributeLists.SelectMany(x => x.Attributes)) + { + if (attribute.Name.ToString() is not ("Solution" or "SolutionAttribute")) + continue; + + var arguments = attribute.ArgumentList?.Arguments ?? default; + var generateProjects = arguments.Any(x => + x.NameEquals?.Name.Identifier.Text == "GenerateProjects" && + x.Expression is LiteralExpressionSyntax { Token.Value: true }); + if (!generateProjects) + continue; + + var fancyNames = arguments.Any(x => + x.NameEquals?.Name.Identifier.Text == "FancyNames" && + x.Expression is LiteralExpressionSyntax { Token.Value: true }); + + var relativePath = arguments + .Where(x => x.NameEquals == null && x.NameColon == null) + .Select(x => (x.Expression as LiteralExpressionSyntax)?.Token.Value as string) + .FirstOrDefault(x => !string.IsNullOrEmpty(x)); + + var memberName = member switch + { + FieldDeclarationSyntax field => field.Declaration.Variables.First().Identifier.Text, + PropertyDeclarationSyntax property => property.Identifier.Text, + _ => null, + }; + + if (memberName != null) + yield return (memberName, relativePath, fancyNames); + } + } + } +} + +static AbsolutePath GetSolutionFileFromParametersFile(AbsolutePath rootDirectory, string memberName) +{ + var parametersFile = Constants.GetDefaultParametersFile(rootDirectory); + Assert.FileExists(parametersFile); + var obj = JsonNode.Parse(File.ReadAllText(parametersFile)).NotNull().AsObject(); + var solutionRelativePath = obj[memberName].NotNull($"Property '{memberName}' does not exist in '{parametersFile}'.").GetValue(); + return rootDirectory / solutionRelativePath.NotNull(); +} diff --git a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj index 4bb79c6b1..0b94b8db1 100644 --- a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj +++ b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj @@ -11,6 +11,11 @@ + + + + + diff --git a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs index 689f6ef9a..7e63a5795 100644 --- a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs +++ b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs @@ -1,22 +1,21 @@ -using System; -using System.Collections.Generic; +using System; using System.IO; using System.Linq; using System.Text.Json.Nodes; -using System.Text.RegularExpressions; using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Fallout.Common; using Fallout.Common.IO; using Fallout.Solutions; using Fallout.Common.Utilities; -using Fallout.Common.Utilities.Collections; -using Scriban; -using static Microsoft.CodeAnalysis.CSharp.SyntaxFactory; namespace Fallout.SourceGenerators; +/// +/// In-compiler generator for the strongly-typed [Solution] accessor. This is the +/// toggle fallback (live in-IDE updates); the default is the net10 pre-build +/// Fallout.Solution.Codegen console. Both share , +/// so output is identical. The MSBuild targets suppress this generator when console-mode is active. +/// [Generator] public class StronglyTypedSolutionGenerator : ISourceGenerator { @@ -47,93 +46,7 @@ public void Execute(GeneratorExecutionContext context) var fancyNaming = attribute.NamedArguments.SingleOrDefault(x => x.Key == "FancyNames").Value.Value as bool? ?? false; var solution = solutionFile.ReadSolution(); - var hintName = member.Name + ".g.cs"; - var declaration = GetDeclaration(solution); - - // lang=csharp - context.AddSource(hintName, $""" - // - - using Fallout.Persistence.Solution.Model; - using Fallout.Solutions; - using Fallout.Common.IO; - using System.Runtime.CompilerServices; - - {declaration} - """); - - continue; - - string GetDeclaration(IProjectContainer container, string folderName = null) - { - var prefix = new string('_', container.Descendants(x => x.Parent).Count() + 1); - var model = new - { - IsSolution = container is Solution, - SolutionReference = container is Solution ? "this" : "solution", - Name = GetEscapedName(container switch - { - Solution => member.Name, - SolutionFolder folder => folderName ?? folder.Name, - _ => throw new ArgumentOutOfRangeException(nameof(container), container, null) - }), - Projects = container.Projects.OrderBy(x => x.Name).Select(x => new - { - x.Name, - EscapedName = GetEscapedName(x.Name), - }), - Folders = container.SolutionFolders.OrderBy(x => x.Name).Select(x => new - { - x.Name, - TypeName = $"{prefix}{GetEscapedName(x.Name)}", - EscapedName = GetEscapedName(x.Name), - }), - Declarations = container.SolutionFolders.OrderBy(x => x.Name) - .Select(x => GetDeclaration(x, $"{prefix}{GetEscapedName(x.Name)}")).ToArray(), - }; - - // lang=csharp - var template = Template.Parse(""" - {{~ if is_solution ~}} - internal class {{ name }}(SolutionModel model, AbsolutePath path) : Fallout.Solutions.Solution(model, path) - {{~ else ~}} - internal class {{ name }}(SolutionFolderModel model, Fallout.Solutions.Solution solution) : Fallout.Solutions.SolutionFolder(model, solution) - {{~ end ~}} - { - {{~ for project in projects ~}} - public Fallout.Solutions.Project {{ project.escaped_name }} => this.GetProject("{{ project.name }}"); - {{~ end ~}} - - {{~ for folder in folders ~}} - public {{ folder.type_name }} {{ folder.escaped_name }} => Unsafe.As<{{ folder.type_name }}>(this.GetSolutionFolder("{{ folder.name }}")); - {{~ end ~}} - - {{~ for declaration in declarations ~}} - {{ declaration }} - {{~ end ~}} - } - """); - return template.Render(model); - - // C# identifiers can't start with a digit and can only contain - // letters/digits/underscore. The old regex `(^[\W^\d]|[\W])` was - // attempting both but the `^\d` inside the character class was - // misread as "match `^` or `\d`" rather than "starts with digit". - // Folders like `00-Build` produced invalid output. Upstream fix - // from nuke-build/nuke#1581. - string GetEscapedName(string name) - { - name = name - // .Replace(".", fancyNaming ? "丨" : "_") - .Replace(".", fancyNaming ? "٠" : "_") - .ReplaceRegex(@"[^A-Za-z0-9_]", _ => "_"); - - if (Regex.IsMatch(name, @"^[0-9]")) - return "_" + name; - - return name; - } - } + context.AddSource(member.Name + ".g.cs", SolutionEmitter.Emit(solution, member.Name, fancyNaming)); } } catch (Exception exception) diff --git a/src/shared/SolutionEmitter.cs b/src/shared/SolutionEmitter.cs new file mode 100644 index 000000000..9e934de2b --- /dev/null +++ b/src/shared/SolutionEmitter.cs @@ -0,0 +1,101 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using Fallout.Common.Utilities; +using Fallout.Common.Utilities.Collections; +using Scriban; + +namespace Fallout.Solutions; + +/// +/// Shared emit logic for the strongly-typed [Solution] accessor (Solution.g.cs). +/// Compiled (via linked source) into both the in-compiler StronglyTypedSolutionGenerator +/// (the toggle fallback) and the net10 pre-build Fallout.Solution.Codegen console (the +/// default), so the two paths produce byte-identical output. +/// +internal static class SolutionEmitter +{ + public static string Emit(Solution solution, string memberName, bool fancyNaming) + { + var declaration = GetDeclaration(solution); + + // lang=csharp + return $""" + // + + using Fallout.Persistence.Solution.Model; + using Fallout.Solutions; + using Fallout.Common.IO; + using System.Runtime.CompilerServices; + + {declaration} + """; + + string GetDeclaration(IProjectContainer container, string folderName = null) + { + var prefix = new string('_', container.Descendants(x => x.Parent).Count() + 1); + var model = new + { + IsSolution = container is Solution, + SolutionReference = container is Solution ? "this" : "solution", + Name = GetEscapedName(container switch + { + Solution => memberName, + SolutionFolder folder => folderName ?? folder.Name, + _ => throw new ArgumentOutOfRangeException(nameof(container), container, null) + }), + Projects = container.Projects.OrderBy(x => x.Name).Select(x => new + { + x.Name, + EscapedName = GetEscapedName(x.Name), + }), + Folders = container.SolutionFolders.OrderBy(x => x.Name).Select(x => new + { + x.Name, + TypeName = $"{prefix}{GetEscapedName(x.Name)}", + EscapedName = GetEscapedName(x.Name), + }), + Declarations = container.SolutionFolders.OrderBy(x => x.Name) + .Select(x => GetDeclaration(x, $"{prefix}{GetEscapedName(x.Name)}")).ToArray(), + }; + + // lang=csharp + var template = Template.Parse(""" + {{~ if is_solution ~}} + internal class {{ name }}(SolutionModel model, AbsolutePath path) : Fallout.Solutions.Solution(model, path) + {{~ else ~}} + internal class {{ name }}(SolutionFolderModel model, Fallout.Solutions.Solution solution) : Fallout.Solutions.SolutionFolder(model, solution) + {{~ end ~}} + { + {{~ for project in projects ~}} + public Fallout.Solutions.Project {{ project.escaped_name }} => this.GetProject("{{ project.name }}"); + {{~ end ~}} + + {{~ for folder in folders ~}} + public {{ folder.type_name }} {{ folder.escaped_name }} => Unsafe.As<{{ folder.type_name }}>(this.GetSolutionFolder("{{ folder.name }}")); + {{~ end ~}} + + {{~ for declaration in declarations ~}} + {{ declaration }} + {{~ end ~}} + } + """); + return template.Render(model); + + // C# identifiers can't start with a digit and can only contain + // letters/digits/underscore. Folders like `00-Build` would otherwise + // produce invalid output (nuke-build/nuke#1581). + string GetEscapedName(string name) + { + name = name + .Replace(".", fancyNaming ? "٠" : "_") + .ReplaceRegex(@"[^A-Za-z0-9_]", _ => "_"); + + if (Regex.IsMatch(name, @"^[0-9]")) + return "_" + name; + + return name; + } + } + } +} From cbed65ac14df2a998dad760fcea8379994a9f35a Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 29 Jun 2026 23:07:26 +1200 Subject: [PATCH 2/9] Wire the Solution.g codegen toggle + dogfood the console path (Lever 1, steps 1-2) - StronglyTypedSolutionGenerator self-suppresses when FalloutSolutionCodegenMode == Build (reads it via CompilerVisibleProperty), so exactly one path emits. - src/Fallout.Solution.Codegen/build/Fallout.Solution.Codegen.targets: reusable pre-build target that runs the console (via FalloutSolutionCodegenProject or a published FalloutSolutionCodegenAssembly), writes .g.cs into obj/, and includes it. Exposes the toggle to the fallback generator. - build/_build.csproj: dogfoods the console path (FalloutSolutionCodegenMode=Build). Verified: the target runs the console, emits Solution.g.cs from fallout.slnx, the generator suppresses, and _build compiles (0 errors). Full fallout.slnx build green. Default is unchanged for everyone else (generator runs unless a project opts into Build), so this is non-breaking. Remaining follow-ups: ship the console + targets in the consumer package and flip the default to Build, then drop the now-removable cone refs from Fallout.SourceGenerators. Co-Authored-By: Claude Opus 4.8 (1M context) --- build/_build.csproj | 9 ++++ .../build/Fallout.Solution.Codegen.targets | 45 +++++++++++++++++++ .../StronglyTypedSolutionGenerator.cs | 6 +++ 3 files changed, 60 insertions(+) create mode 100644 src/Fallout.Solution.Codegen/build/Fallout.Solution.Codegen.targets diff --git a/build/_build.csproj b/build/_build.csproj index cee68e3d4..069426f46 100644 --- a/build/_build.csproj +++ b/build/_build.csproj @@ -14,6 +14,14 @@ false + + + Build + $(MSBuildProjectDirectory)\..\src\Fallout.Solution.Codegen\Fallout.Solution.Codegen.csproj + $(MSBuildProjectDirectory)\.. + + False @@ -72,5 +80,6 @@ + diff --git a/src/Fallout.Solution.Codegen/build/Fallout.Solution.Codegen.targets b/src/Fallout.Solution.Codegen/build/Fallout.Solution.Codegen.targets new file mode 100644 index 000000000..db80e5574 --- /dev/null +++ b/src/Fallout.Solution.Codegen/build/Fallout.Solution.Codegen.targets @@ -0,0 +1,45 @@ + + + + + + + + + + + + <_FalloutSolutionCodegenOut>$(IntermediateOutputPath)FalloutSolution + <_FalloutSolutionCodegenRoot Condition="'$(FalloutBaseDirectory)' != ''">$(FalloutBaseDirectory) + <_FalloutSolutionCodegenRoot Condition="'$(_FalloutSolutionCodegenRoot)' == ''">$(MSBuildProjectDirectory) + <_FalloutSolutionCodegenInvoke Condition="'$(FalloutSolutionCodegenAssembly)' != ''">dotnet "$(FalloutSolutionCodegenAssembly)" + <_FalloutSolutionCodegenInvoke Condition="'$(_FalloutSolutionCodegenInvoke)' == '' and '$(FalloutSolutionCodegenProject)' != ''">dotnet run -c $(Configuration) --project "$(FalloutSolutionCodegenProject)" -- + + + + + + + + + + + + + diff --git a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs index 7e63a5795..199cdc540 100644 --- a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs +++ b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs @@ -27,6 +27,12 @@ public void Execute(GeneratorExecutionContext context) { try { + // Toggle: when the net10 pre-build console owns solution codegen, this fallback no-ops + // (so only one path emits Solution.g.cs). Default/unset keeps the generator running. + context.AnalyzerConfigOptions.GlobalOptions.TryGetValue("build_property.FalloutSolutionCodegenMode", out var codegenMode); + if (string.Equals(codegenMode, "Build", StringComparison.OrdinalIgnoreCase)) + return; + var allTypes = context.Compilation.Assembly.GlobalNamespace.GetAllTypes(); var members = allTypes.SelectMany(x => x.GetMembers()) .Where(x => x is IPropertySymbol or IFieldSymbol) From 9567f9a2910a58f4d5d4ada31f71795455fba651 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Mon, 29 Jun 2026 23:17:19 +1200 Subject: [PATCH 3/9] Update StronglyTypedSolutionGenerator Verify snapshot for the added Fallout.Solution.Codegen project The generator's output for fallout.slnx now includes the new Fallout.Solution.Codegen project; the only snapshot change is that one accessor line (confirming the shared-emitter refactor preserved output). BOM stripped to match the repo's no-BOM verified convention. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...ronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs index 33249a2c3..e93743d4e 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs @@ -32,6 +32,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_ProjectModel => this.GetProject("Fallout.ProjectModel"); public Fallout.Solutions.Project Fallout_ProjectModel_Specs => this.GetProject("Fallout.ProjectModel.Specs"); public Fallout.Solutions.Project Fallout_Solution => this.GetProject("Fallout.Solution"); + public Fallout.Solutions.Project Fallout_Solution_Codegen => this.GetProject("Fallout.Solution.Codegen"); public Fallout.Solutions.Project Fallout_Solution_Specs => this.GetProject("Fallout.Solution.Specs"); public Fallout.Solutions.Project Fallout_SourceGenerators => this.GetProject("Fallout.SourceGenerators"); public Fallout.Solutions.Project Fallout_SourceGenerators_Specs => this.GetProject("Fallout.SourceGenerators.Specs"); From a835b7173c715c9b588997d7258754d065f76ce8 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 12:53:56 +1200 Subject: [PATCH 4/9] Harden Solution.g codegen discovery and clear stale outputs Extract the syntactic [Solution] discovery out of Program into a testable SolutionMemberDiscovery, and tighten it to match the symbol-based generator: - Match the attribute by its rightmost name segment so qualified/aliased usages ([Fallout.Solutions.Solution], [Solutions.Solution], [global::...Attribute]) are recognised, not just the bare [Solution]/[SolutionAttribute]. - Accept the relativePath constructor argument written positionally or as 'relativePath:' (NameColon). - Prune bin/obj/hidden directories in the bare --root fallback scan instead of walking AllDirectories. - Delete any existing *.g.cs in the output directory before emitting, so a removed or renamed [Solution] member can't leave an orphan that still compiles. GenerateProjects/FancyNames stay NameEquals-only on purpose: they are settable properties, so '=' is the only valid syntax. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Solution.Codegen/Program.cs | 57 ++------- .../SolutionMemberDiscovery.cs | 121 ++++++++++++++++++ 2 files changed, 129 insertions(+), 49 deletions(-) create mode 100644 src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs diff --git a/src/Fallout.Solution.Codegen/Program.cs b/src/Fallout.Solution.Codegen/Program.cs index 1fa4580f9..b4353b587 100644 --- a/src/Fallout.Solution.Codegen/Program.cs +++ b/src/Fallout.Solution.Codegen/Program.cs @@ -3,11 +3,10 @@ using System.IO; using System.Linq; using System.Text.Json.Nodes; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Fallout.Common; using Fallout.Common.IO; using Fallout.Common.Utilities; +using Fallout.SolutionCodegen; using Fallout.Solutions; // Pre-build codegen for the strongly-typed [Solution] accessor. @@ -23,12 +22,17 @@ var outDir = GetOption("--out") ?? Path.Combine(root, "obj"); var sources = GetOptions("--source").Where(File.Exists).ToList(); if (sources.Count == 0) - sources = Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories).ToList(); + sources = SolutionMemberDiscovery.EnumerateProjectSources(root).ToList(); var rootDirectory = (AbsolutePath)root; Directory.CreateDirectory(outDir); -foreach (var (memberName, relativePath, fancyNames) in DiscoverSolutionMembers(sources)) +// Regenerate from a clean slate: drop any *.g.cs from a previous run so a removed or renamed +// [Solution] member can't leave an orphan that the build still compiles. +foreach (var stale in Directory.EnumerateFiles(outDir, "*.g.cs")) + File.Delete(stale); + +foreach (var (memberName, relativePath, fancyNames) in SolutionMemberDiscovery.Discover(sources)) { var solutionFile = !string.IsNullOrEmpty(relativePath) ? rootDirectory / relativePath @@ -54,51 +58,6 @@ IEnumerable GetOptions(string name) yield return args[i + 1]; } -static IEnumerable<(string MemberName, string RelativePath, bool FancyNames)> DiscoverSolutionMembers(IEnumerable files) -{ - foreach (var file in files) - { - var root = CSharpSyntaxTree.ParseText(File.ReadAllText(file)).GetRoot(); - foreach (var member in root.DescendantNodes().OfType()) - { - if (member is not (FieldDeclarationSyntax or PropertyDeclarationSyntax)) - continue; - - foreach (var attribute in member.AttributeLists.SelectMany(x => x.Attributes)) - { - if (attribute.Name.ToString() is not ("Solution" or "SolutionAttribute")) - continue; - - var arguments = attribute.ArgumentList?.Arguments ?? default; - var generateProjects = arguments.Any(x => - x.NameEquals?.Name.Identifier.Text == "GenerateProjects" && - x.Expression is LiteralExpressionSyntax { Token.Value: true }); - if (!generateProjects) - continue; - - var fancyNames = arguments.Any(x => - x.NameEquals?.Name.Identifier.Text == "FancyNames" && - x.Expression is LiteralExpressionSyntax { Token.Value: true }); - - var relativePath = arguments - .Where(x => x.NameEquals == null && x.NameColon == null) - .Select(x => (x.Expression as LiteralExpressionSyntax)?.Token.Value as string) - .FirstOrDefault(x => !string.IsNullOrEmpty(x)); - - var memberName = member switch - { - FieldDeclarationSyntax field => field.Declaration.Variables.First().Identifier.Text, - PropertyDeclarationSyntax property => property.Identifier.Text, - _ => null, - }; - - if (memberName != null) - yield return (memberName, relativePath, fancyNames); - } - } - } -} - static AbsolutePath GetSolutionFileFromParametersFile(AbsolutePath rootDirectory, string memberName) { var parametersFile = Constants.GetDefaultParametersFile(rootDirectory); diff --git a/src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs b/src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs new file mode 100644 index 000000000..b206af69c --- /dev/null +++ b/src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs @@ -0,0 +1,121 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace Fallout.SolutionCodegen; + +/// +/// A field/property annotated with [Solution(GenerateProjects = true)], discovered +/// syntactically (no compilation is available pre-build). +/// +internal record SolutionMember(string MemberName, string RelativePath, bool FancyNames); + +/// +/// Pre-build, purely syntactic discovery of [Solution(GenerateProjects = true)] members. +/// Mirrors the symbol-based StronglyTypedSolutionGenerator as closely as syntax allows so +/// the console and the in-compiler fallback agree on what to emit. +/// +internal static class SolutionMemberDiscovery +{ + public static IEnumerable Discover(IEnumerable files) + { + foreach (var file in files) + foreach (var member in DiscoverInText(File.ReadAllText(file))) + yield return member; + } + + public static IEnumerable DiscoverInText(string text) + { + var root = CSharpSyntaxTree.ParseText(text).GetRoot(); + foreach (var member in root.DescendantNodes().OfType()) + { + if (member is not (FieldDeclarationSyntax or PropertyDeclarationSyntax)) + continue; + + var memberName = GetMemberName(member); + if (memberName == null) + continue; + + foreach (var attribute in member.AttributeLists.SelectMany(x => x.Attributes)) + { + // Match by the rightmost name segment so qualified / aliased usages are recognised: + // [Solution], [SolutionAttribute], [Solutions.Solution], [Fallout.Solutions.Solution], + // [global::Fallout.Solutions.SolutionAttribute], ... + if (GetUnqualifiedAttributeName(attribute) != "Solution") + continue; + + var arguments = attribute.ArgumentList?.Arguments ?? default; + + // GenerateProjects / FancyNames are settable properties -> only ever named with '=' + // (NameEquals). 'name:' (NameColon) names a constructor parameter, of which there is + // only one (relativePath), so it is never valid for these flags. + if (!arguments.Any(x => IsNamedFlagTrue(x, "GenerateProjects"))) + continue; + + var fancyNames = arguments.Any(x => IsNamedFlagTrue(x, "FancyNames")); + var relativePath = GetRelativePath(arguments); + + yield return new SolutionMember(memberName, relativePath, fancyNames); + } + } + } + + /// + /// Enumerates *.cs under , pruning bin/obj and hidden + /// directories (e.g. .git) so the fallback scan stays fast and never picks up intermediate + /// or generated sources. Only used when the build doesn't pass explicit --source files. + /// + public static IEnumerable EnumerateProjectSources(string root) + { + foreach (var file in Directory.EnumerateFiles(root, "*.cs")) + yield return file; + + foreach (var directory in Directory.EnumerateDirectories(root)) + { + var name = Path.GetFileName(directory); + if (name is "bin" or "obj" || name.StartsWith(".")) + continue; + + foreach (var file in EnumerateProjectSources(directory)) + yield return file; + } + } + + private static string GetUnqualifiedAttributeName(AttributeSyntax attribute) + { + var name = attribute.Name switch + { + QualifiedNameSyntax qualified => qualified.Right.Identifier.Text, + AliasQualifiedNameSyntax aliasQualified => aliasQualified.Name.Identifier.Text, + SimpleNameSyntax simple => simple.Identifier.Text, + var other => other.ToString(), + }; + + const string suffix = "Attribute"; + return name.EndsWith(suffix) ? name[..^suffix.Length] : name; + } + + private static bool IsNamedFlagTrue(AttributeArgumentSyntax argument, string name) => + argument.NameEquals?.Name.Identifier.Text == name && + argument.Expression is LiteralExpressionSyntax { Token.Value: true }; + + private static string GetRelativePath(SeparatedSyntaxList arguments) => + arguments + // The relativePath constructor argument: positional, or written 'relativePath:' (NameColon). + // Exclude property assignments (NameEquals) like GenerateProjects = true. + .Where(x => x.NameEquals == null && + (x.NameColon == null || x.NameColon.Name.Identifier.Text == "relativePath")) + .Select(x => (x.Expression as LiteralExpressionSyntax)?.Token.Value as string) + .FirstOrDefault(x => !string.IsNullOrEmpty(x)); + + private static string GetMemberName(MemberDeclarationSyntax member) => + member switch + { + FieldDeclarationSyntax field => field.Declaration.Variables.First().Identifier.Text, + PropertyDeclarationSyntax property => property.Identifier.Text, + _ => null, + }; +} From e8ed6ea294affc2e248172583f1a257c43c8dc6a Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 12:53:56 +1200 Subject: [PATCH 5/9] Add Fallout.Solution.Codegen.Specs covering [Solution] discovery Unit tests for SolutionMemberDiscovery: rightmost-segment attribute matching, positional vs. relativePath: arguments, FancyNames, property vs. field members, and the negatives (GenerateProjects=false, unrelated attributes). Wires the new project into fallout.slnx and InternalsVisibleTo. The generator Verify snapshot gains one accessor line for the newly-added test project (the generator enumerates the real solution). Co-Authored-By: Claude Opus 4.8 (1M context) --- AssemblyInfo.cs | 1 + fallout.slnx | 1 + .../Fallout.Solution.Codegen.Specs.csproj | 11 +++ .../SolutionMemberDiscoverySpecs.cs | 89 +++++++++++++++++++ ...GeneratorSpecs.Test#Solution.g.verified.cs | 1 + 5 files changed, 103 insertions(+) create mode 100644 tests/Fallout.Solution.Codegen.Specs/Fallout.Solution.Codegen.Specs.csproj create mode 100644 tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs index c7362d976..866015f83 100644 --- a/AssemblyInfo.cs +++ b/AssemblyInfo.cs @@ -10,6 +10,7 @@ [assembly: InternalsVisibleTo("Fallout.ProjectModel.Specs")] [assembly: InternalsVisibleTo("Fallout.SourceGenerators")] [assembly: InternalsVisibleTo("Fallout.Solution.Codegen")] +[assembly: InternalsVisibleTo("Fallout.Solution.Codegen.Specs")] [assembly: InternalsVisibleTo("Fallout.Solution")] [assembly: InternalsVisibleTo("Fallout.Solution.Specs")] [assembly: InternalsVisibleTo("Fallout.Persistence.Solution")] diff --git a/fallout.slnx b/fallout.slnx index 79fada201..32a548dc2 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -47,6 +47,7 @@ + diff --git a/tests/Fallout.Solution.Codegen.Specs/Fallout.Solution.Codegen.Specs.csproj b/tests/Fallout.Solution.Codegen.Specs/Fallout.Solution.Codegen.Specs.csproj new file mode 100644 index 000000000..46df54c2c --- /dev/null +++ b/tests/Fallout.Solution.Codegen.Specs/Fallout.Solution.Codegen.Specs.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs new file mode 100644 index 000000000..caf4e09fb --- /dev/null +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using Fallout.SolutionCodegen; +using Xunit; + +namespace Fallout.Solution.Codegen.Specs; + +public class SolutionMemberDiscoverySpecs +{ + [Theory] + [InlineData("[Solution(GenerateProjects = true)]")] + [InlineData("[SolutionAttribute(GenerateProjects = true)]")] + [InlineData("[Solutions.Solution(GenerateProjects = true)]")] + [InlineData("[Fallout.Solutions.Solution(GenerateProjects = true)]")] + [InlineData("[global::Fallout.Solutions.SolutionAttribute(GenerateProjects = true)]")] + public void Matches_attribute_by_rightmost_name_segment(string attribute) + { + Discover(attribute, "readonly Solution Solution;") + .Should().ContainSingle().Which.MemberName.Should().Be("Solution"); + } + + [Theory] + [InlineData("[Solution(GenerateProjects = false)]")] + [InlineData("[Solution]")] + [InlineData("[Solution(\"x.sln\")]")] + public void Ignores_members_not_opting_into_GenerateProjects(string attribute) + { + Discover(attribute, "readonly Solution Solution;").Should().BeEmpty(); + } + + [Theory] + [InlineData("[Parameter]")] + [InlineData("[MySolution(GenerateProjects = true)]")] + public void Ignores_unrelated_attributes(string attribute) + { + Discover(attribute, "readonly Solution Solution;").Should().BeEmpty(); + } + + [Theory] + [InlineData("[Solution(\"sub/a.sln\", GenerateProjects = true)]")] + [InlineData("[Solution(relativePath: \"sub/a.sln\", GenerateProjects = true)]")] + public void Captures_relative_path_whether_positional_or_named(string attribute) + { + Discover(attribute, "readonly Solution Solution;") + .Single().RelativePath.Should().Be("sub/a.sln"); + } + + [Fact] + public void Relative_path_is_null_when_omitted() + { + Discover("[Solution(GenerateProjects = true)]", "readonly Solution Solution;") + .Single().RelativePath.Should().BeNull(); + } + + [Fact] + public void Captures_FancyNames_flag() + { + Discover("[Solution(GenerateProjects = true, FancyNames = true)]", "readonly Solution Solution;") + .Single().FancyNames.Should().BeTrue(); + } + + [Fact] + public void FancyNames_defaults_to_false() + { + Discover("[Solution(GenerateProjects = true)]", "readonly Solution Solution;") + .Single().FancyNames.Should().BeFalse(); + } + + [Fact] + public void Discovers_property_members() + { + Discover("[Solution(GenerateProjects = true)]", "public Solution MySolution { get; set; }") + .Single().MemberName.Should().Be("MySolution"); + } + + private static IReadOnlyList Discover(string attribute, string memberDeclaration) + { + var source = + $$""" + partial class Build + { + {{attribute}} + {{memberDeclaration}} + } + """; + return SolutionMemberDiscovery.DiscoverInText(source).ToList(); + } +} diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs index e93743d4e..dcb6e85fc 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs @@ -33,6 +33,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_ProjectModel_Specs => this.GetProject("Fallout.ProjectModel.Specs"); public Fallout.Solutions.Project Fallout_Solution => this.GetProject("Fallout.Solution"); public Fallout.Solutions.Project Fallout_Solution_Codegen => this.GetProject("Fallout.Solution.Codegen"); + public Fallout.Solutions.Project Fallout_Solution_Codegen_Specs => this.GetProject("Fallout.Solution.Codegen.Specs"); public Fallout.Solutions.Project Fallout_Solution_Specs => this.GetProject("Fallout.Solution.Specs"); public Fallout.Solutions.Project Fallout_SourceGenerators => this.GetProject("Fallout.SourceGenerators"); public Fallout.Solutions.Project Fallout_SourceGenerators_Specs => this.GetProject("Fallout.SourceGenerators.Specs"); From 4027622a4317a60be665ee0e3a91017df5e8de38 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 13:02:40 +1200 Subject: [PATCH 6/9] Extract SolutionCodegenRunner and cover the emit pipeline end-to-end Move the discover -> resolve -> emit loop out of Program's top-level statements into a testable SolutionCodegenRunner; Program is now a thin arg-parsing shell. This puts the previously untested output side-effects under test: - emits the accessor for a discovered member against a real (minimal) .slnx - clears stale *.g.cs before emitting (orphan removal) - removes all outputs when no member remains Also adds a test for EnumerateProjectSources confirming bin/obj/hidden dirs are pruned from the bare --root fallback scan. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Fallout.Solution.Codegen/Program.cs | 33 +------- .../SolutionCodegenRunner.cs | 56 +++++++++++++ .../SolutionCodegenRunnerSpecs.cs | 78 +++++++++++++++++++ .../SourceEnumerationSpecs.cs | 45 +++++++++++ 4 files changed, 181 insertions(+), 31 deletions(-) create mode 100644 src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs create mode 100644 tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs create mode 100644 tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs diff --git a/src/Fallout.Solution.Codegen/Program.cs b/src/Fallout.Solution.Codegen/Program.cs index b4353b587..41610c813 100644 --- a/src/Fallout.Solution.Codegen/Program.cs +++ b/src/Fallout.Solution.Codegen/Program.cs @@ -2,12 +2,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Text.Json.Nodes; -using Fallout.Common; using Fallout.Common.IO; -using Fallout.Common.Utilities; using Fallout.SolutionCodegen; -using Fallout.Solutions; // Pre-build codegen for the strongly-typed [Solution] accessor. // @@ -24,24 +20,8 @@ if (sources.Count == 0) sources = SolutionMemberDiscovery.EnumerateProjectSources(root).ToList(); -var rootDirectory = (AbsolutePath)root; -Directory.CreateDirectory(outDir); - -// Regenerate from a clean slate: drop any *.g.cs from a previous run so a removed or renamed -// [Solution] member can't leave an orphan that the build still compiles. -foreach (var stale in Directory.EnumerateFiles(outDir, "*.g.cs")) - File.Delete(stale); - -foreach (var (memberName, relativePath, fancyNames) in SolutionMemberDiscovery.Discover(sources)) -{ - var solutionFile = !string.IsNullOrEmpty(relativePath) - ? rootDirectory / relativePath - : GetSolutionFileFromParametersFile(rootDirectory, memberName); - - var solution = solutionFile.ReadSolution(); - File.WriteAllText(Path.Combine(outDir, memberName + ".g.cs"), SolutionEmitter.Emit(solution, memberName, fancyNames)); - Console.WriteLine($"Fallout.Solution.Codegen: generated {memberName}.g.cs from {solutionFile}"); -} +foreach (var (fileName, solutionFile) in SolutionCodegenRunner.Run((AbsolutePath)root, outDir, sources)) + Console.WriteLine($"Fallout.Solution.Codegen: generated {fileName} from {solutionFile}"); return 0; @@ -57,12 +37,3 @@ IEnumerable GetOptions(string name) if (args[i] == name) yield return args[i + 1]; } - -static AbsolutePath GetSolutionFileFromParametersFile(AbsolutePath rootDirectory, string memberName) -{ - var parametersFile = Constants.GetDefaultParametersFile(rootDirectory); - Assert.FileExists(parametersFile); - var obj = JsonNode.Parse(File.ReadAllText(parametersFile)).NotNull().AsObject(); - var solutionRelativePath = obj[memberName].NotNull($"Property '{memberName}' does not exist in '{parametersFile}'.").GetValue(); - return rootDirectory / solutionRelativePath.NotNull(); -} diff --git a/src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs b/src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs new file mode 100644 index 000000000..634303aaa --- /dev/null +++ b/src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.IO; +using System.Text.Json.Nodes; +using Fallout.Common; +using Fallout.Common.IO; +using Fallout.Common.Utilities; +using Fallout.Solutions; + +namespace Fallout.SolutionCodegen; + +/// +/// The codegen pipeline: discover [Solution(GenerateProjects = true)] members in the given +/// sources, resolve each solution file, and (re)write <Member>.g.cs into the output +/// directory via the shared . +/// +internal static class SolutionCodegenRunner +{ + /// Repo root; relative solution paths resolve against it. + /// Directory the *.g.cs files are written to. + /// C# files to scan for the attribute. + /// The generated file name and resolved solution file for each emitted member. + public static IReadOnlyList<(string FileName, AbsolutePath SolutionFile)> Run( + AbsolutePath rootDirectory, string outDir, IEnumerable sources) + { + Directory.CreateDirectory(outDir); + + // Regenerate from a clean slate: drop any *.g.cs from a previous run so a removed or renamed + // [Solution] member can't leave an orphan that the build still compiles. + foreach (var stale in Directory.EnumerateFiles(outDir, "*.g.cs")) + File.Delete(stale); + + var generated = new List<(string, AbsolutePath)>(); + foreach (var (memberName, relativePath, fancyNames) in SolutionMemberDiscovery.Discover(sources)) + { + var solutionFile = !string.IsNullOrEmpty(relativePath) + ? rootDirectory / relativePath + : GetSolutionFileFromParametersFile(rootDirectory, memberName); + + var solution = solutionFile.ReadSolution(); + var fileName = memberName + ".g.cs"; + File.WriteAllText(Path.Combine(outDir, fileName), SolutionEmitter.Emit(solution, memberName, fancyNames)); + generated.Add((fileName, solutionFile)); + } + + return generated; + } + + private static AbsolutePath GetSolutionFileFromParametersFile(AbsolutePath rootDirectory, string memberName) + { + var parametersFile = Constants.GetDefaultParametersFile(rootDirectory); + Assert.FileExists(parametersFile); + var obj = JsonNode.Parse(File.ReadAllText(parametersFile)).NotNull().AsObject(); + var solutionRelativePath = obj[memberName].NotNull($"Property '{memberName}' does not exist in '{parametersFile}'.").GetValue(); + return rootDirectory / solutionRelativePath.NotNull(); + } +} diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs new file mode 100644 index 000000000..20c410afa --- /dev/null +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs @@ -0,0 +1,78 @@ +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using Fallout.Common.IO; +using Fallout.SolutionCodegen; +using Xunit; + +namespace Fallout.Solution.Codegen.Specs; + +public class SolutionCodegenRunnerSpecs : IDisposable +{ + private readonly AbsolutePath _root; + private readonly string _outDir; + + public SolutionCodegenRunnerSpecs() + { + _root = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-codegen-" + Path.GetRandomFileName()); + _outDir = Path.Combine(_root, "out"); + Directory.CreateDirectory(_outDir); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Fact] + public void Emits_accessor_for_discovered_member_from_the_real_solution() + { + WriteBuild("""[Solution("app.slnx", GenerateProjects = true)] readonly Solution Solution;"""); + WriteSolution("app.slnx", "src/Alpha/Alpha.csproj", "src/Beta/Beta.csproj"); + + var generated = SolutionCodegenRunner.Run(_root, _outDir, new[] { (_root / "Build.cs").ToString() }); + + generated.Select(x => x.FileName).Should().Equal("Solution.g.cs"); + var emitted = File.ReadAllText(Path.Combine(_outDir, "Solution.g.cs")); + emitted.Should().Contain("GetProject(\"Alpha\")").And.Contain("GetProject(\"Beta\")"); + } + + [Fact] + public void Clears_stale_outputs_before_emitting() + { + // a *.g.cs left by a previous run whose [Solution] member has since been removed/renamed + File.WriteAllText(Path.Combine(_outDir, "Removed.g.cs"), "// orphan"); + WriteBuild("""[Solution("app.slnx", GenerateProjects = true)] readonly Solution Solution;"""); + WriteSolution("app.slnx", "src/Alpha/Alpha.csproj"); + + SolutionCodegenRunner.Run(_root, _outDir, new[] { (_root / "Build.cs").ToString() }); + + File.Exists(Path.Combine(_outDir, "Removed.g.cs")).Should().BeFalse(); + File.Exists(Path.Combine(_outDir, "Solution.g.cs")).Should().BeTrue(); + } + + [Fact] + public void Removes_all_outputs_when_no_member_remains() + { + File.WriteAllText(Path.Combine(_outDir, "Solution.g.cs"), "// stale"); + WriteBuild("readonly Solution Solution;"); // no [Solution] attribute anymore + + var generated = SolutionCodegenRunner.Run(_root, _outDir, new[] { (_root / "Build.cs").ToString() }); + + generated.Should().BeEmpty(); + Directory.EnumerateFiles(_outDir, "*.g.cs").Should().BeEmpty(); + } + + private void WriteBuild(string memberLine) => + File.WriteAllText(_root / "Build.cs", + $$""" + partial class Build + { + {{memberLine}} + } + """); + + private void WriteSolution(string name, params string[] projectPaths) => + File.WriteAllText(_root / name, + "" + Environment.NewLine + + string.Concat(projectPaths.Select(p => $" {Environment.NewLine}")) + + ""); +} diff --git a/tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs new file mode 100644 index 000000000..cd1d0aa4b --- /dev/null +++ b/tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs @@ -0,0 +1,45 @@ +using System; +using System.IO; +using System.Linq; +using FluentAssertions; +using Fallout.SolutionCodegen; +using Xunit; + +namespace Fallout.Solution.Codegen.Specs; + +public class SourceEnumerationSpecs : IDisposable +{ + private readonly string _root; + + public SourceEnumerationSpecs() + { + _root = Path.Combine(Path.GetTempPath(), "fallout-sources-" + Path.GetRandomFileName()); + Directory.CreateDirectory(_root); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Fact] + public void Enumerates_sources_recursively_but_skips_bin_obj_and_hidden_dirs() + { + Write("Build.cs"); + Write("src/Project/Component.cs"); + Write("bin/Debug/Generated.cs"); + Write("obj/Debug/Solution.g.cs"); + Write(".git/hooks/Sneaky.cs"); + Write("src/.cache/Cached.cs"); + + var found = SolutionMemberDiscovery.EnumerateProjectSources(_root) + .Select(x => Path.GetFileName(x)) + .ToList(); + + found.Should().BeEquivalentTo("Build.cs", "Component.cs"); + } + + private void Write(string relativePath) + { + var full = Path.Combine(_root, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(full)!); + File.WriteAllText(full, "// test"); + } +} From 229562d2c576aee1d307ae3d8eba7b6312561853 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 13:24:08 +1200 Subject: [PATCH 7/9] Replace the /shared SolutionEmitter link with a real project The emitter was linked-source into both the Roslyn generator and the net10 console via a src/shared/ directory. Promote it to a proper multi-targeted project, Fallout.Solution.Codegen.Emit (netstandard2.0;net10.0), that both reference: - netstandard2.0 so the in-compiler StronglyTypedSolutionGenerator (also ns2.0) can reference it; its DLL is bundled into the analyzer via the existing GetDependencyTargetPaths mechanism. - net10.0 so the console gets a modern build. When the generator fallback is retired (#441), the ns2.0 target can simply be dropped. SolutionEmitter becomes public (it now crosses an assembly boundary). Scriban moves to the emit project and reaches the console transitively. No /shared. It stays a dedicated codegen library rather than folding into Fallout.Solution: the emitter pulls in Scriban, which shouldn't leak into everything that consumes the Solution model. Output is still byte-identical (the generator Verify snapshot passes; the one delta is the new project's own accessor line). Co-Authored-By: Claude Opus 4.8 (1M context) --- fallout.slnx | 1 + .../Fallout.Solution.Codegen.Emit.csproj | 21 +++++++++++++++++++ .../SolutionEmitter.cs | 8 +++---- .../Fallout.Solution.Codegen.csproj | 11 ++++------ .../Fallout.SourceGenerators.csproj | 9 ++++---- ...GeneratorSpecs.Test#Solution.g.verified.cs | 1 + 6 files changed, 35 insertions(+), 16 deletions(-) create mode 100644 src/Fallout.Solution.Codegen.Emit/Fallout.Solution.Codegen.Emit.csproj rename src/{shared => Fallout.Solution.Codegen.Emit}/SolutionEmitter.cs (92%) diff --git a/fallout.slnx b/fallout.slnx index 32a548dc2..db38eca20 100644 --- a/fallout.slnx +++ b/fallout.slnx @@ -28,6 +28,7 @@ + diff --git a/src/Fallout.Solution.Codegen.Emit/Fallout.Solution.Codegen.Emit.csproj b/src/Fallout.Solution.Codegen.Emit/Fallout.Solution.Codegen.Emit.csproj new file mode 100644 index 000000000..c78cf8db9 --- /dev/null +++ b/src/Fallout.Solution.Codegen.Emit/Fallout.Solution.Codegen.Emit.csproj @@ -0,0 +1,21 @@ + + + + + netstandard2.0;net10.0 + false + Shared emit logic for the strongly-typed [Solution] accessor (Solution.g.cs). Consumed by both the in-compiler StronglyTypedSolutionGenerator (ns2.0 fallback) and the net10 Fallout.Solution.Codegen console, so both produce byte-identical output. + + + + + + + + + + + + diff --git a/src/shared/SolutionEmitter.cs b/src/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs similarity index 92% rename from src/shared/SolutionEmitter.cs rename to src/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs index 9e934de2b..7c1c080f1 100644 --- a/src/shared/SolutionEmitter.cs +++ b/src/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs @@ -9,11 +9,11 @@ namespace Fallout.Solutions; /// /// Shared emit logic for the strongly-typed [Solution] accessor (Solution.g.cs). -/// Compiled (via linked source) into both the in-compiler StronglyTypedSolutionGenerator -/// (the toggle fallback) and the net10 pre-build Fallout.Solution.Codegen console (the -/// default), so the two paths produce byte-identical output. +/// Referenced by both the in-compiler StronglyTypedSolutionGenerator (the ns2.0 toggle +/// fallback) and the net10 pre-build Fallout.Solution.Codegen console (the default), so the +/// two paths produce byte-identical output. /// -internal static class SolutionEmitter +public static class SolutionEmitter { public static string Emit(Solution solution, string memberName, bool fancyNaming) { diff --git a/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj b/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj index f54edf0d6..8574037df 100644 --- a/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj +++ b/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj @@ -4,22 +4,19 @@ Exe net10.0 false - Pre-build codegen for the strongly-typed [Solution] accessor (Solution.g.cs). The net10 default that frees the Roslyn generator from the solution-parser dependency cone; reuses the real Fallout.Persistence.Solution parser and the shared SolutionEmitter. + Pre-build codegen for the strongly-typed [Solution] accessor (Solution.g.cs). The net10 default that frees the Roslyn generator from the solution-parser dependency cone; reuses the real Fallout.Persistence.Solution parser and the shared Fallout.Solution.Codegen.Emit emitter. - - - - - + + - + diff --git a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj index 0b94b8db1..b13d018f1 100644 --- a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj +++ b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj @@ -12,11 +12,9 @@ - - - - - + + @@ -44,6 +42,7 @@ + diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs index dcb6e85fc..44dfbca65 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.Test#Solution.g.verified.cs @@ -33,6 +33,7 @@ internal class Solution(SolutionModel model, AbsolutePath path) : Fallout.Soluti public Fallout.Solutions.Project Fallout_ProjectModel_Specs => this.GetProject("Fallout.ProjectModel.Specs"); public Fallout.Solutions.Project Fallout_Solution => this.GetProject("Fallout.Solution"); public Fallout.Solutions.Project Fallout_Solution_Codegen => this.GetProject("Fallout.Solution.Codegen"); + public Fallout.Solutions.Project Fallout_Solution_Codegen_Emit => this.GetProject("Fallout.Solution.Codegen.Emit"); public Fallout.Solutions.Project Fallout_Solution_Codegen_Specs => this.GetProject("Fallout.Solution.Codegen.Specs"); public Fallout.Solutions.Project Fallout_Solution_Specs => this.GetProject("Fallout.Solution.Specs"); public Fallout.Solutions.Project Fallout_SourceGenerators => this.GetProject("Fallout.SourceGenerators"); From 87685b1897a6b41083e39fc8310536599bcb3475 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 14:05:52 +1200 Subject: [PATCH 8/9] Expand test coverage for Solution.g codegen Fills the gaps in the previously thin spots: - Generator self-suppression: asserts the in-compiler generator no-ops when FalloutSolutionCodegenMode=Build (case-insensitive) and still emits otherwise. This is the toggle that guarantees exactly one path emits Solution.g.cs. - Parameters-file fallback: the runner resolves a [Solution] member with no path argument via .fallout/parameters.json. - Multiple members: discovery and the runner both handle more than one [Solution(GenerateProjects = true)] member, emitting one file each. - Emitter escaping: dotted project names become valid identifiers, and names starting with a digit get an underscore prefix. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SolutionCodegenRunnerSpecs.cs | 32 ++++++++++ .../SolutionEmitterSpecs.cs | 48 +++++++++++++++ .../SolutionMemberDiscoverySpecs.cs | 17 ++++++ .../StronglyTypedSolutionGeneratorSpecs.cs | 60 +++++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs index 20c410afa..312ba8738 100644 --- a/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs @@ -61,6 +61,38 @@ public void Removes_all_outputs_when_no_member_remains() Directory.EnumerateFiles(_outDir, "*.g.cs").Should().BeEmpty(); } + [Fact] + public void Resolves_solution_from_parameters_file_when_no_path_given() + { + // No path on the attribute -> the solution is looked up in .fallout/parameters.json by member name. + Directory.CreateDirectory(_root / ".fallout"); + File.WriteAllText(_root / ".fallout" / "parameters.json", """{ "Solution": "app.slnx" }"""); + WriteSolution("app.slnx", "src/Alpha/Alpha.csproj"); + WriteBuild("[Solution(GenerateProjects = true)] readonly Solution Solution;"); + + var generated = SolutionCodegenRunner.Run(_root, _outDir, new[] { (_root / "Build.cs").ToString() }); + + generated.Select(x => x.FileName).Should().Equal("Solution.g.cs"); + File.ReadAllText(Path.Combine(_outDir, "Solution.g.cs")).Should().Contain("GetProject(\"Alpha\")"); + } + + [Fact] + public void Emits_one_file_per_member_for_multiple_solutions() + { + WriteBuild(""" + [Solution("a.slnx", GenerateProjects = true)] readonly Solution First; + [Solution("b.slnx", GenerateProjects = true)] readonly Solution Second; + """); + WriteSolution("a.slnx", "src/Alpha/Alpha.csproj"); + WriteSolution("b.slnx", "src/Beta/Beta.csproj"); + + var generated = SolutionCodegenRunner.Run(_root, _outDir, new[] { (_root / "Build.cs").ToString() }); + + generated.Select(x => x.FileName).Should().BeEquivalentTo("First.g.cs", "Second.g.cs"); + File.ReadAllText(Path.Combine(_outDir, "First.g.cs")).Should().Contain("GetProject(\"Alpha\")"); + File.ReadAllText(Path.Combine(_outDir, "Second.g.cs")).Should().Contain("GetProject(\"Beta\")"); + } + private void WriteBuild(string memberLine) => File.WriteAllText(_root / "Build.cs", $$""" diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs new file mode 100644 index 000000000..2b280091b --- /dev/null +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using FluentAssertions; +using Fallout.Common.IO; +using Fallout.Solutions; +using Xunit; + +namespace Fallout.Solution.Codegen.Specs; + +public class SolutionEmitterSpecs : IDisposable +{ + private readonly AbsolutePath _root; + + public SolutionEmitterSpecs() + { + _root = (AbsolutePath)Path.Combine(Path.GetTempPath(), "fallout-emitter-" + Path.GetRandomFileName()); + Directory.CreateDirectory(_root); + } + + public void Dispose() => Directory.Delete(_root, recursive: true); + + [Fact] + public void Escapes_dotted_project_names_into_valid_identifiers() + { + var solution = WriteAndReadSolution("src/Foo.Bar/Foo.Bar.csproj"); + + var emitted = SolutionEmitter.Emit(solution, "Sln", fancyNaming: false); + + emitted.Should().Contain("Foo_Bar => this.GetProject(\"Foo.Bar\")"); + } + + [Fact] + public void Prefixes_underscore_when_a_project_name_starts_with_a_digit() + { + var solution = WriteAndReadSolution("src/00-Build/00-Build.csproj"); + + var emitted = SolutionEmitter.Emit(solution, "Sln", fancyNaming: false); + + emitted.Should().Contain("_00_Build => this.GetProject(\"00-Build\")"); + } + + private Fallout.Solutions.Solution WriteAndReadSolution(string projectPath) + { + var slnx = _root / "app.slnx"; + File.WriteAllText(slnx, $"{Environment.NewLine} {Environment.NewLine}"); + return slnx.ReadSolution(); + } +} diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs index caf4e09fb..24add7887 100644 --- a/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs @@ -74,6 +74,23 @@ public void Discovers_property_members() .Single().MemberName.Should().Be("MySolution"); } + [Fact] + public void Discovers_every_annotated_member_in_a_file() + { + var source = + """ + partial class Build + { + [Solution("a.slnx", GenerateProjects = true)] readonly Solution First; + [Solution(GenerateProjects = false)] readonly Solution Ignored; + [Solution("b.slnx", GenerateProjects = true)] readonly Solution Second; + } + """; + + SolutionMemberDiscovery.DiscoverInText(source).Select(x => x.MemberName) + .Should().Equal("First", "Second"); + } + private static IReadOnlyList Discover(string attribute, string memberDeclaration) { var source = diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs index aab87ff5b..5c83a17a7 100644 --- a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs +++ b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Diagnostics; using Fallout.Common; using Fallout.Solutions; using VerifyXunit; @@ -80,6 +82,64 @@ partial class Build : FalloutBuild result.GeneratedTrees.Should().BeEmpty(); } + [Theory] + [InlineData("Build")] + [InlineData("build")] + [InlineData("BUILD")] + public void Suppresses_itself_when_codegen_mode_is_build(string mode) + { + // When the net10 pre-build console owns codegen (FalloutSolutionCodegenMode=Build), the + // in-compiler generator must no-op so exactly one path emits Solution.g.cs. + var result = RunWithCodegenMode(mode); + + if (!result.Diagnostics.IsEmpty) + throw new Exception(string.Join(Environment.NewLine, result.Diagnostics.Select(x => x.GetMessage()))); + result.GeneratedTrees.Should().BeEmpty(); + } + + [Fact] + public void Still_generates_when_codegen_mode_is_not_build() + { + var result = RunWithCodegenMode("Generator"); + + result.GeneratedTrees.Should().ContainSingle(); + } + + private static GeneratorDriverRunResult RunWithCodegenMode(string mode) + { + var inputCompilation = CreateCompilation(""" + using Fallout.Common; + using Fallout.Solutions; + partial class Build : FalloutBuild + { + [Solution(GenerateProjects = true)] + readonly Solution Solution; + } + """); + + var options = new TestOptionsProvider(("build_property.FalloutSolutionCodegenMode", mode)); + var driver = CSharpGeneratorDriver.Create( + new ISourceGenerator[] { new StronglyTypedSolutionGenerator() }, + additionalTexts: null, parseOptions: null, optionsProvider: options); + return driver.RunGenerators(inputCompilation).GetRunResult(); + } + + private sealed class TestOptionsProvider(params (string Key, string Value)[] global) + : AnalyzerConfigOptionsProvider + { + public override AnalyzerConfigOptions GlobalOptions { get; } = new TestOptions(global); + public override AnalyzerConfigOptions GetOptions(SyntaxTree tree) => new TestOptions(); + public override AnalyzerConfigOptions GetOptions(AdditionalText textFile) => new TestOptions(); + } + + private sealed class TestOptions(params (string Key, string Value)[] entries) : AnalyzerConfigOptions + { + private readonly Dictionary _values = + entries.ToDictionary(x => x.Key, x => x.Value); + + public override bool TryGetValue(string key, out string value) => _values.TryGetValue(key, out value); + } + private static Compilation CreateCompilation(string source) { return CSharpCompilation.Create("compilation", From 16192ed2f315e62e7851def6ec10db0e977751c4 Mon Sep 17 00:00:00 2001 From: Chrison Simtian Date: Tue, 30 Jun 2026 20:52:38 +1200 Subject: [PATCH 9/9] Cover the emitter's nested solution-folder path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a spec exercising the recursive folder declaration (Unsafe.As / nested SolutionFolder class) — previously only covered transitively by the generator snapshot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SolutionEmitterSpecs.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs index 2b280091b..9077e092b 100644 --- a/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs +++ b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs @@ -39,6 +39,24 @@ public void Prefixes_underscore_when_a_project_name_starts_with_a_digit() emitted.Should().Contain("_00_Build => this.GetProject(\"00-Build\")"); } + [Fact] + public void Emits_nested_declarations_for_solution_folders() + { + var slnx = _root / "app.slnx"; + File.WriteAllText(slnx, + "" + Environment.NewLine + + " " + Environment.NewLine + + " " + Environment.NewLine + + " " + Environment.NewLine + + ""); + + var emitted = SolutionEmitter.Emit(slnx.ReadSolution(), "Sln", fancyNaming: false); + + emitted.Should().Contain("Unsafe.As<").And.Contain("this.GetSolutionFolder("); + emitted.Should().Contain(": Fallout.Solutions.SolutionFolder("); + emitted.Should().Contain("GetProject(\"Inner\")"); + } + private Fallout.Solutions.Solution WriteAndReadSolution(string projectPath) { var slnx = _root / "app.slnx";