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",