diff --git a/AssemblyInfo.cs b/AssemblyInfo.cs
index df15df35..866015f8 100644
--- a/AssemblyInfo.cs
+++ b/AssemblyInfo.cs
@@ -9,6 +9,8 @@
[assembly: InternalsVisibleTo("Fallout.Cli.Specs")]
[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/build/_build.csproj b/build/_build.csproj
index cee68e3d..069426f4 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/fallout.slnx b/fallout.slnx
index 808eb0ea..db38eca2 100644
--- a/fallout.slnx
+++ b/fallout.slnx
@@ -27,6 +27,8 @@
+
+
@@ -46,6 +48,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 00000000..c78cf8db
--- /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/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs b/src/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs
new file mode 100644
index 00000000..7c1c080f
--- /dev/null
+++ b/src/Fallout.Solution.Codegen.Emit/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).
+/// 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.
+///
+public 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;
+ }
+ }
+ }
+}
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 00000000..8574037d
--- /dev/null
+++ b/src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj
@@ -0,0 +1,23 @@
+
+
+
+ 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 Fallout.Solution.Codegen.Emit emitter.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Fallout.Solution.Codegen/Program.cs b/src/Fallout.Solution.Codegen/Program.cs
new file mode 100644
index 00000000..41610c81
--- /dev/null
+++ b/src/Fallout.Solution.Codegen/Program.cs
@@ -0,0 +1,39 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Fallout.Common.IO;
+using Fallout.SolutionCodegen;
+
+// 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 = SolutionMemberDiscovery.EnumerateProjectSources(root).ToList();
+
+foreach (var (fileName, solutionFile) in SolutionCodegenRunner.Run((AbsolutePath)root, outDir, sources))
+ Console.WriteLine($"Fallout.Solution.Codegen: generated {fileName} 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];
+}
diff --git a/src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs b/src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs
new file mode 100644
index 00000000..634303aa
--- /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/src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs b/src/Fallout.Solution.Codegen/SolutionMemberDiscovery.cs
new file mode 100644
index 00000000..b206af69
--- /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,
+ };
+}
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 00000000..db80e557
--- /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/Fallout.SourceGenerators.csproj b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj
index 4bb79c6b..b13d018f 100644
--- a/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj
+++ b/src/Fallout.SourceGenerators/Fallout.SourceGenerators.csproj
@@ -12,6 +12,9 @@
+
+
@@ -39,6 +42,7 @@
+
diff --git a/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs b/src/Fallout.SourceGenerators/StronglyTypedSolutionGenerator.cs
index 689f6ef9..199cdc54 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
{
@@ -28,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)
@@ -47,93 +52,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/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 00000000..46df54c2
--- /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/SolutionCodegenRunnerSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs
new file mode 100644
index 00000000..312ba873
--- /dev/null
+++ b/tests/Fallout.Solution.Codegen.Specs/SolutionCodegenRunnerSpecs.cs
@@ -0,0 +1,110 @@
+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();
+ }
+
+ [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",
+ $$"""
+ 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/SolutionEmitterSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs
new file mode 100644
index 00000000..9077e092
--- /dev/null
+++ b/tests/Fallout.Solution.Codegen.Specs/SolutionEmitterSpecs.cs
@@ -0,0 +1,66 @@
+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\")");
+ }
+
+ [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";
+ 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
new file mode 100644
index 00000000..24add788
--- /dev/null
+++ b/tests/Fallout.Solution.Codegen.Specs/SolutionMemberDiscoverySpecs.cs
@@ -0,0 +1,106 @@
+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");
+ }
+
+ [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 =
+ $$"""
+ partial class Build
+ {
+ {{attribute}}
+ {{memberDeclaration}}
+ }
+ """;
+ return SolutionMemberDiscovery.DiscoverInText(source).ToList();
+ }
+}
diff --git a/tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs b/tests/Fallout.Solution.Codegen.Specs/SourceEnumerationSpecs.cs
new file mode 100644
index 00000000..cd1d0aa4
--- /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");
+ }
+}
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 33249a2c..44dfbca6 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,9 @@ 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_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");
public Fallout.Solutions.Project Fallout_SourceGenerators_Specs => this.GetProject("Fallout.SourceGenerators.Specs");
diff --git a/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs b/tests/Fallout.SourceGenerators.Specs/StronglyTypedSolutionGeneratorSpecs.cs
index aab87ff5..5c83a17a 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",