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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AssemblyInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down
9 changes: 9 additions & 0 deletions build/_build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@
<IsPackable>false</IsPackable>
</PropertyGroup>

<!-- Dogfood the net10 pre-build [Solution] codegen (the StronglyTypedSolutionGenerator fallback
self-suppresses while this is Build). -->
<PropertyGroup>
<FalloutSolutionCodegenMode>Build</FalloutSolutionCodegenMode>
<FalloutSolutionCodegenProject>$(MSBuildProjectDirectory)\..\src\Fallout.Solution.Codegen\Fallout.Solution.Codegen.csproj</FalloutSolutionCodegenProject>
<FalloutBaseDirectory>$(MSBuildProjectDirectory)\..</FalloutBaseDirectory>
</PropertyGroup>

<!-- Test properties for MSBuild integration -->
<PropertyGroup>
<FalloutTasksEnabled Condition="'$(FalloutTasksEnabled)' == ''">False</FalloutTasksEnabled>
Expand Down Expand Up @@ -72,5 +80,6 @@
</ItemGroup>

<Import Project="..\src\Fallout.Common\Fallout.Common.targets" />
<Import Project="..\src\Fallout.Solution.Codegen\build\Fallout.Solution.Codegen.targets" />

</Project>
3 changes: 3 additions & 0 deletions fallout.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
<Project Path="src\Persistence\Fallout.Persistence.Solution\Fallout.Persistence.Solution.csproj" />
<Project Path="src\Persistence\Fallout.Solution\Fallout.Solution.csproj" />
<Project Path="src\Fallout.SourceGenerators\Fallout.SourceGenerators.csproj" />
<Project Path="src\Fallout.Solution.Codegen\Fallout.Solution.Codegen.csproj" />
<Project Path="src\Fallout.Solution.Codegen.Emit\Fallout.Solution.Codegen.Emit.csproj" />
<Project Path="src\Fallout.Tooling.Generator\Fallout.Tooling.Generator.csproj" />
<Project Path="src\Fallout.Tooling\Fallout.Tooling.csproj" />
<Project Path="src\Fallout.Utilities.IO.Compression\Fallout.Utilities.IO.Compression.csproj" />
Expand All @@ -46,6 +48,7 @@
<Project Path="tests\Nuke.Components.Shim.Specs\Nuke.Components.Shim.Specs.csproj" />
<Project Path="tests\Fallout.ProjectModel.Specs\Fallout.ProjectModel.Specs.csproj" />
<Project Path="tests\Fallout.Solution.Specs\Fallout.Solution.Specs.csproj" />
<Project Path="tests\Fallout.Solution.Codegen.Specs\Fallout.Solution.Codegen.Specs.csproj" />
<Project Path="tests\Fallout.SourceGenerators.Specs\Fallout.SourceGenerators.Specs.csproj" />
<Project Path="tests\Benchmarks\Fallout.Persistence.Solution.Benchmarks\Fallout.Persistence.Solution.Benchmarks.csproj" />
<Project Path="tests\Consumers\Nuke.Consumer\Nuke.Consumer.csproj" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- ns2.0 so the Roslyn StronglyTypedSolutionGenerator (also ns2.0) can reference it;
net10 so the Fallout.Solution.Codegen console gets a modern build. When the generator
fallback is eventually retired (#441), the ns2.0 target can be dropped. -->
<TargetFrameworks>netstandard2.0;net10.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<Description>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.</Description>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\Persistence\Fallout.Solution\Fallout.Solution.csproj" />
<ProjectReference Include="..\Fallout.Utilities\Fallout.Utilities.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Scriban" />
</ItemGroup>

</Project>
101 changes: 101 additions & 0 deletions src/Fallout.Solution.Codegen.Emit/SolutionEmitter.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Shared emit logic for the strongly-typed <c>[Solution]</c> accessor (<c>Solution.g.cs</c>).
/// Referenced by both the in-compiler <c>StronglyTypedSolutionGenerator</c> (the ns2.0 toggle
/// fallback) and the net10 pre-build <c>Fallout.Solution.Codegen</c> console (the default), so the
/// two paths produce byte-identical output.
/// </summary>
public static class SolutionEmitter
{
public static string Emit(Solution solution, string memberName, bool fancyNaming)
{
var declaration = GetDeclaration(solution);

// lang=csharp
return $"""
// <auto-generated/>

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;
}
}
}
}
23 changes: 23 additions & 0 deletions src/Fallout.Solution.Codegen/Fallout.Solution.Codegen.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<IsPackable>false</IsPackable>
<Description>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.</Description>
</PropertyGroup>

<ItemGroup>
<!-- Shared emitter, also referenced by the in-compiler StronglyTypedSolutionGenerator -> identical output. -->
<ProjectReference Include="..\Fallout.Solution.Codegen.Emit\Fallout.Solution.Codegen.Emit.csproj" />
<ProjectReference Include="..\Persistence\Fallout.Solution\Fallout.Solution.csproj" />
<ProjectReference Include="..\Fallout.Build.Shared\Fallout.Build.Shared.csproj" />
<ProjectReference Include="..\Fallout.Utilities\Fallout.Utilities.csproj" />
</ItemGroup>

<ItemGroup>
<!-- Scriban arrives transitively via Fallout.Solution.Codegen.Emit. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" />
</ItemGroup>

</Project>
39 changes: 39 additions & 0 deletions src/Fallout.Solution.Codegen/Program.cs
Original file line number Diff line number Diff line change
@@ -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 <rootDir> --out <outDir> [--source <file> ...]
//
// Discovers [Solution(GenerateProjects = true)] members by parsing the given source files
// syntactically (no compilation available pre-build), resolves each solution file, and writes
// <MemberName>.g.cs into <outDir> 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<string> GetOptions(string name)
{
for (var i = 0; i < args.Length - 1; i++)
if (args[i] == name)
yield return args[i + 1];
}
56 changes: 56 additions & 0 deletions src/Fallout.Solution.Codegen/SolutionCodegenRunner.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The codegen pipeline: discover <c>[Solution(GenerateProjects = true)]</c> members in the given
/// sources, resolve each solution file, and (re)write <c>&lt;Member&gt;.g.cs</c> into the output
/// directory via the shared <see cref="SolutionEmitter"/>.
/// </summary>
internal static class SolutionCodegenRunner
{
/// <param name="rootDirectory">Repo root; relative solution paths resolve against it.</param>
/// <param name="outDir">Directory the <c>*.g.cs</c> files are written to.</param>
/// <param name="sources">C# files to scan for the attribute.</param>
/// <returns>The generated file name and resolved solution file for each emitted member.</returns>
public static IReadOnlyList<(string FileName, AbsolutePath SolutionFile)> Run(
AbsolutePath rootDirectory, string outDir, IEnumerable<string> 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<string>();
return rootDirectory / solutionRelativePath.NotNull();
}
}
Loading
Loading