Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/ModularPipelines.Build/ModularPipelines.Build.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModularPipelines.Analyzers\ModularPipelines.Analyzers\ModularPipelines.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" SetTargetFramework="TargetFramework=netstandard2.0" />
<ProjectReference Include="..\ModularPipelines.SourceGenerator\ModularPipelines.SourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" SetTargetFramework="TargetFramework=netstandard2.0" />
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
Expand Down
16 changes: 13 additions & 3 deletions src/ModularPipelines.SourceGenerator/BuildMethodGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,18 @@
namespace ModularPipelines.SourceGenerator;

/// <summary>
/// Generates the Build() method for options classes.
/// Generates the BuildCommandLine() method for options classes.
/// </summary>
/// <remarks>
/// The method is named BuildCommandLine() instead of Build() to avoid conflicts with
/// options classes that have properties named "Build" (e.g., AptGetOptions has a Build
/// property for the --build flag). In C#, a property and parameterless method cannot
/// share the same name.
///
/// This method is internal infrastructure called by ICommandLineBuilder - users don't
/// call it directly on options instances. They pass options to command methods like
/// context.Git().Checkout(options).
/// </remarks>
internal static class BuildMethodGenerator
{
/// <summary>
Expand All @@ -19,7 +29,7 @@ internal static class BuildMethodGenerator
private const string GeneratorVersion = "1.0.0";

/// <summary>
/// Generates the source code for the Build() method.
/// Generates the source code for the BuildCommandLine() method.
/// </summary>
/// <param name="info">The options class information.</param>
/// <returns>The generated source code.</returns>
Expand All @@ -46,7 +56,7 @@ public static string Generate(OptionsClassInfo info)
sb.AppendLine(" /// <summary>");
sb.AppendLine(" /// Builds the command line arguments from this options instance.");
sb.AppendLine(" /// </summary>");
sb.AppendLine(" public ModularPipelines.Models.CommandLine Build()");
sb.AppendLine(" public ModularPipelines.Models.CommandLine BuildCommandLine()");
sb.AppendLine(" {");
sb.AppendLine(" var args = new System.Collections.Generic.List<string>();");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace ModularPipelines.SourceGenerator;

/// <summary>
/// Source generator that validates CommandLineToolOptions classes
/// and generates optimized Build() methods.
/// and generates optimized BuildCommandLine() methods.
/// </summary>
[Generator]
public sealed class CommandOptionsGenerator : IIncrementalGenerator
Expand Down Expand Up @@ -237,7 +237,7 @@ private static void GenerateCode(SourceProductionContext context, OptionsClassIn
propNames, group.Key));
}

// Generate Build() method
// Generate BuildCommandLine() method
var source = BuildMethodGenerator.Generate(info);
context.AddSource($"{info.ClassName}.g.cs", source);
}
Expand Down
14 changes: 14 additions & 0 deletions src/ModularPipelines.SourceGenerator/ModuleClassInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using Microsoft.CodeAnalysis;

namespace ModularPipelines.SourceGenerator;

/// <summary>
/// Information about a Module class discovered by the generator.
/// </summary>
internal sealed record ModuleClassInfo(
string Namespace,
string ClassName,
string ResultTypeName,
string ResultTypeFullName,
Location Location
);
233 changes: 233 additions & 0 deletions src/ModularPipelines.SourceGenerator/ModuleExtensionsGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace ModularPipelines.SourceGenerator;

/// <summary>
/// Source generator that creates type-safe GetModule extension methods for Module classes.
/// For each class that inherits from Module&lt;T&gt;, generates:
/// - GetXxxModuleResult(this IModuleContext context) extension method (strips "Module" suffix)
/// - GetXxxModuleResultIfRegistered(this IModuleContext context) extension method
/// </summary>
[Generator]
public sealed class ModuleExtensionsGenerator : IIncrementalGenerator
{
/// <summary>
/// The fully qualified name of the Module&lt;T&gt; base class.
/// </summary>
internal const string ModuleBaseFullName = "ModularPipelines.Modules.Module`1";

private const string GeneratorName = "ModularPipelines.SourceGenerator";
private const string GeneratorVersion = "1.0.0";

public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Create syntax provider that finds class declarations with base types
var moduleClasses = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) => IsCandidate(node),
transform: static (ctx, _) => GetModuleClassInfo(ctx))
.Where(static info => info is not null)
.Select(static (info, _) => info!);

// Collect all modules and generate a single extensions file
var collectedModules = moduleClasses.Collect();
context.RegisterSourceOutput(collectedModules, static (ctx, modules) => GenerateExtensions(ctx, modules));
}

/// <summary>
/// Checks if a syntax node is a potential candidate for module discovery.
/// Returns true for class declarations with base types.
/// </summary>
private static bool IsCandidate(SyntaxNode node)
{
return node is ClassDeclarationSyntax classDeclaration &&
classDeclaration.BaseList != null &&
classDeclaration.BaseList.Types.Count > 0;
}

/// <summary>
/// Extracts ModuleClassInfo from a type declaration if it inherits from Module&lt;T&gt;.
/// </summary>
private static ModuleClassInfo? GetModuleClassInfo(GeneratorSyntaxContext context)
{
var classDeclaration = (ClassDeclarationSyntax)context.Node;
var semanticModel = context.SemanticModel;

// Get the declared symbol for this type
if (semanticModel.GetDeclaredSymbol(classDeclaration) is not INamedTypeSymbol typeSymbol)
{
return null;
}

// Skip abstract classes - they can't be instantiated as modules
if (typeSymbol.IsAbstract)
{
return null;
}

// Check if this type inherits from Module<T> and get the result type
var resultType = GetModuleResultType(typeSymbol, semanticModel.Compilation);
if (resultType is null)
{
return null;
}

// Extract namespace
var namespaceName = typeSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: typeSymbol.ContainingNamespace.ToDisplayString();

// Get type names for code generation
var resultTypeName = resultType.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
var resultTypeFullName = resultType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);

return new ModuleClassInfo(
Namespace: namespaceName,
ClassName: typeSymbol.Name,
ResultTypeName: resultTypeName,
ResultTypeFullName: resultTypeFullName,
Location: classDeclaration.Identifier.GetLocation()
);
}

/// <summary>
/// Gets the result type T from Module&lt;T&gt; if the type inherits from it.
/// </summary>
private static ITypeSymbol? GetModuleResultType(INamedTypeSymbol type, Compilation compilation)
{
var moduleBaseType = compilation.GetTypeByMetadataName(ModuleBaseFullName);
if (moduleBaseType is null)
{
return null;
}

var current = type.BaseType;
while (current is not null)
{
if (current.IsGenericType &&
SymbolEqualityComparer.Default.Equals(current.OriginalDefinition, moduleBaseType))
{
return current.TypeArguments[0];
}

current = current.BaseType;
}

return null;
}

/// <summary>
/// Generates the extension methods file containing all module accessors.
/// </summary>
private static void GenerateExtensions(SourceProductionContext context, ImmutableArray<ModuleClassInfo> modules)
{
if (modules.IsEmpty)
{
return;
}

var sb = new StringBuilder();
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine();
sb.AppendLine("using System.CodeDom.Compiler;");
sb.AppendLine("using ModularPipelines.Context;");
sb.AppendLine("using ModularPipelines.Models;");
sb.AppendLine("using ModularPipelines.Modules;");
sb.AppendLine();

// Add using statements for module namespaces
var distinctNamespaces = modules
.Select(m => m.Namespace)
.Where(ns => !string.IsNullOrEmpty(ns))
.Distinct()
.OrderBy(ns => ns);

foreach (var ns in distinctNamespaces)
{
sb.AppendLine($"using {ns};");
}

sb.AppendLine();
sb.AppendLine("namespace ModularPipelines.Generated;");
sb.AppendLine();
sb.AppendLine("/// <summary>");
sb.AppendLine("/// Type-safe extension methods for retrieving module results.");
sb.AppendLine("/// </summary>");
sb.AppendLine($"[GeneratedCode(\"{GeneratorName}\", \"{GeneratorVersion}\")]");
sb.AppendLine("public static class ModuleContextExtensions");
sb.AppendLine("{");

// Group modules by class name to handle potential duplicates
var modulesByName = modules.GroupBy(m => m.ClassName).ToList();

foreach (var moduleGroup in modulesByName)
{
var module = moduleGroup.First();
var methodName = StripModuleSuffix(module.ClassName);

// GetXxxModuleResult method - returns non-nullable (e.g., GetBuildModuleResult for BuildModule)
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Gets the result of <see cref=\"{EscapeXmlComment(module.ClassName)}\"/>.");
sb.AppendLine($" /// </summary>");
sb.AppendLine($" /// <param name=\"context\">The module context.</param>");
sb.AppendLine($" /// <returns>The module result.</returns>");
sb.AppendLine($" /// <exception cref=\"ModularPipelines.Exceptions.ModuleNotRegisteredException\">Thrown when the module is not registered.</exception>");
sb.AppendLine($" public static ModuleResult<{module.ResultTypeFullName}> Get{methodName}ModuleResult(this IModuleContext context)");
sb.AppendLine($" => context.GetModule<{module.ClassName}, {module.ResultTypeFullName}>();");
sb.AppendLine();

// GetXxxModuleResultIfRegistered method - returns nullable
sb.AppendLine($" /// <summary>");
sb.AppendLine($" /// Gets the result of <see cref=\"{EscapeXmlComment(module.ClassName)}\"/> if it is registered, otherwise null.");
sb.AppendLine($" /// </summary>");
sb.AppendLine($" /// <param name=\"context\">The module context.</param>");
sb.AppendLine($" /// <returns>The module result, or null if not registered.</returns>");
sb.AppendLine($" public static ModuleResult<{module.ResultTypeFullName}>? Get{methodName}ModuleResultIfRegistered(this IModuleContext context)");
sb.AppendLine($" => context.GetModuleIfRegistered<{module.ClassName}, {module.ResultTypeFullName}>();");
sb.AppendLine();
}

sb.AppendLine("}");

context.AddSource("ModuleContextExtensions.g.cs", sb.ToString());
}

/// <summary>
/// Escapes a string for use in XML documentation comments.
/// </summary>
private static string EscapeXmlComment(string text)
{
return text.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
}

/// <summary>
/// Strips the "Module" suffix from a class name to create a cleaner method name.
/// </summary>
/// <remarks>
/// Examples:
/// - "BuildModule" → "Build" (generates GetBuildModuleResult)
/// - "DeployToProduction" → "DeployToProduction" (generates GetDeployToProductionModuleResult)
/// - "Module" → "Module" (edge case: keeps name to avoid empty string)
///
/// The length check (className.Length > suffix.Length) ensures that a class named
/// exactly "Module" won't be stripped to an empty string.
/// </remarks>
private static string StripModuleSuffix(string className)
{
const string suffix = "Module";

// Only strip if there's actual content before "Module" (prevents "Module" → "")
if (className.Length > suffix.Length && className.EndsWith(suffix))
{
return className.Substring(0, className.Length - suffix.Length);
}

return className;
}
}
10 changes: 10 additions & 0 deletions src/ModularPipelines/ModularPipelines.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,15 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ModularPipelines.Analyzers\ModularPipelines.Analyzers.Package\ModularPipelines.Analyzers.Package.csproj" Name="ModularPipelines.Analyzers" />
<ProjectReference Include="..\ModularPipelines.SourceGenerator\ModularPipelines.SourceGenerator.csproj" ReferenceOutputAssembly="false" OutputItemType="Analyzer" SetTargetFramework="TargetFramework=netstandard2.0" />
</ItemGroup>
<!-- Include source generator in NuGet package for consumers -->
<PropertyGroup>
<TargetsForTfmSpecificContentInPackage>$(TargetsForTfmSpecificContentInPackage);_AddSourceGeneratorToPackage</TargetsForTfmSpecificContentInPackage>
</PropertyGroup>
<Target Name="_AddSourceGeneratorToPackage">
<ItemGroup>
<TfmSpecificPackageFile Include="$(MSBuildThisFileDirectory)..\ModularPipelines.SourceGenerator\bin\$(Configuration)\netstandard2.0\ModularPipelines.SourceGenerator.dll" PackagePath="analyzers/dotnet/cs" />
</ItemGroup>
</Target>
</Project>
2 changes: 1 addition & 1 deletion src/ModularPipelines/Options/BashCommandOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ namespace ModularPipelines.Options;
/// </summary>
/// <param name="Command">The bash command to execute.</param>
[ExcludeFromCodeCoverage]
public record BashCommandOptions([property: CliOption("-c")] string Command) : BashOptions;
public partial record BashCommandOptions([property: CliOption("-c")] string Command) : BashOptions;
2 changes: 1 addition & 1 deletion src/ModularPipelines/Options/BashFileOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
namespace ModularPipelines.Options;

[ExcludeFromCodeCoverage]
public record BashFileOptions([property: CliArgument(Placement = ArgumentPlacement.BeforeOptions)] string FilePath) : BashOptions;
public partial record BashFileOptions([property: CliArgument(Placement = ArgumentPlacement.BeforeOptions)] string FilePath) : BashOptions;
2 changes: 1 addition & 1 deletion src/ModularPipelines/Options/BashOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,4 @@ namespace ModularPipelines.Options;
/// </summary>
[ExcludeFromCodeCoverage]
[CliTool("bash")]
public record BashOptions : CommandLineToolOptions;
public partial record BashOptions : CommandLineToolOptions;
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace ModularPipelines.Options.Linux.AptGet;

[ExcludeFromCodeCoverage]
public record AptGetAutocleanOptions : AptGetOptions
public partial record AptGetAutocleanOptions : AptGetOptions
{
[CliArgument(Placement = ArgumentPlacement.AfterOptions)]
public virtual string CommandName { get; } = "autoclean";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace ModularPipelines.Options.Linux.AptGet;

[ExcludeFromCodeCoverage]
public record AptGetBuildDepOptions : AptGetOptions
public partial record AptGetBuildDepOptions : AptGetOptions
{
[CliArgument(Placement = ArgumentPlacement.AfterOptions)]
public virtual string CommandName { get; } = "build-dep";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace ModularPipelines.Options.Linux.AptGet;

[ExcludeFromCodeCoverage]
public record AptGetCheckOptions : AptGetOptions
public partial record AptGetCheckOptions : AptGetOptions
{
[CliArgument(Placement = ArgumentPlacement.AfterOptions)]
public virtual string CommandName { get; } = "check";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace ModularPipelines.Options.Linux.AptGet;

[ExcludeFromCodeCoverage]
public record AptGetCleanOptions : AptGetOptions
public partial record AptGetCleanOptions : AptGetOptions
{
[CliArgument(Placement = ArgumentPlacement.AfterOptions)]
public virtual string CommandName { get; } = "clean";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
namespace ModularPipelines.Options.Linux.AptGet;

[ExcludeFromCodeCoverage]
public record AptGetDistUpgradeOptions : AptGetOptions
public partial record AptGetDistUpgradeOptions : AptGetOptions
{
[CliArgument(Placement = ArgumentPlacement.AfterOptions)]
public virtual string CommandName { get; } = "dist-upgrade";
Expand Down
Loading
Loading