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
File renamed without changes.
14 changes: 13 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,17 +103,29 @@ return app.Execute(args);
Uses xUnit with FluentAssertions and Moq. Convention tests inherit from `ConventionTestBase` and use `Create<T>()` factory method.

```bash
# Quick test run (may not catch all issues)
dotnet test --collect:"XPlat Code Coverage"

# Full validation (REQUIRED before committing)
pwsh -File build.ps1
```

**IMPORTANT:** Always run the full `build.ps1` script before committing changes. `dotnet test` alone may pass while the full build fails due to:
- Sample project compilation issues
- Source generator output problems
- Integration test failures
- Code coverage requirements

The build script runs the complete validation pipeline including tests, samples, and packaging.

## Development Workflow

**Test-Driven Development:** When implementing new features or fixing bugs, prefer writing tests first:

1. Write a failing test that demonstrates the desired behavior or reproduces the bug
2. Run the test to confirm it fails as expected
3. Implement the minimum code needed to make the test pass
4. Run tests to verify the fix
4. Run the **full build script** (`pwsh -File build.ps1`) to verify the fix
5. Refactor if needed while keeping tests green

This approach ensures code correctness, prevents regressions, and validates that tests actually catch the issues they're meant to detect. The test suite already has good coverage and patterns to follow.
Expand Down
21 changes: 13 additions & 8 deletions src/CommandLineUtils.Generators/CommandMetadataGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ private static void ExtractSpecialProperties(INamedTypeSymbol typeSymbol, Comman
{
info.SpecialProperties.RemainingArgumentsPropertyName = property.Name;
info.SpecialProperties.RemainingArgumentsPropertyType = property.Type.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat);
// Track whether this is an array type (needs special handling for conversion from IReadOnlyList)
info.SpecialProperties.RemainingArgumentsIsArray = property.Type is IArrayTypeSymbol;
}
}
}
Expand Down Expand Up @@ -823,6 +825,7 @@ private static string GenerateMetadataProvider(CommandData info)
sb.AppendLine("using System;");
sb.AppendLine("using System.Collections.Generic;");
sb.AppendLine("using System.ComponentModel.DataAnnotations;");
sb.AppendLine("using System.Linq;");
sb.AppendLine("using System.Threading;");
sb.AppendLine("using System.Threading.Tasks;");
sb.AppendLine("using McMaster.Extensions.CommandLineUtils;");
Expand Down Expand Up @@ -1043,7 +1046,7 @@ private static void GenerateSubcommandsProperty(StringBuilder sb, CommandData in
private static void GenerateCommandDataProperty(StringBuilder sb, CommandData info, string indent)
{
var cmd = info.CommandAttribute;
sb.AppendLine($"{indent} public CommandMetadata? CommandData => new CommandMetadata");
sb.AppendLine($"{indent} public CommandMetadata? CommandInfo => new CommandMetadata");
sb.AppendLine($"{indent} {{");
// Use explicit name if set, otherwise use inferred name from class name
var commandName = cmd.Name ?? info.InferredName;
Expand Down Expand Up @@ -1216,8 +1219,9 @@ private static void GenerateSpecialPropertiesProperty(StringBuilder sb, CommandD

if (sp.RemainingArgumentsPropertyName != null)
{
// Handle string[] vs IReadOnlyList<string>
if (sp.RemainingArgumentsPropertyType == "string[]")
// Handle string[] vs IReadOnlyList<string> based on actual type analysis (not string comparison)
// Array types need conversion from IReadOnlyList, collection types can be cast directly
if (sp.RemainingArgumentsIsArray)
{
sb.AppendLine($"{indent} RemainingArgumentsSetter = static (obj, val) => (({info.ClassName})obj).{sp.RemainingArgumentsPropertyName} = val is string[] arr ? arr : ((System.Collections.Generic.IReadOnlyList<string>)val!).ToArray(),");
}
Expand Down Expand Up @@ -1306,19 +1310,20 @@ private static void GenerateModelFactory(StringBuilder sb, CommandData info, str
var ctor = constructorsWithParams[ctorIdx];

// Generate variable declarations for each parameter
// Use intermediate 'service' variable to avoid 'as' operator issues with value types
for (int paramIdx = 0; paramIdx < ctor.Parameters.Count; paramIdx++)
{
var param = ctor.Parameters[paramIdx];
sb.AppendLine($"{indent} var p{ctorIdx}_{paramIdx} = _services.GetService(typeof({param.TypeName})) as {param.TypeName};");
sb.AppendLine($"{indent} var service{ctorIdx}_{paramIdx} = _services.GetService(typeof({param.TypeName}));");
}

// Check if all parameters were resolved
var allParamsCheck = string.Join(" && ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"p{ctorIdx}_{i} != null"));
// Check if all parameters were resolved (check service objects, not cast results)
var allParamsCheck = string.Join(" && ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"service{ctorIdx}_{i} != null"));
sb.AppendLine($"{indent} if ({allParamsCheck})");
sb.AppendLine($"{indent} {{");

// Create instance with resolved parameters
var paramList = string.Join(", ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"p{ctorIdx}_{i}"));
// Create instance with resolved parameters (cast to correct types)
var paramList = string.Join(", ", Enumerable.Range(0, ctor.Parameters.Count).Select(i => $"({ctor.Parameters[i].TypeName})service{ctorIdx}_{i}!"));
sb.AppendLine($"{indent} return new {info.ClassName}({paramList});");
sb.AppendLine($"{indent} }}");
}
Expand Down
5 changes: 5 additions & 0 deletions src/CommandLineUtils.Generators/SpecialPropertiesData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ internal sealed class SpecialPropertiesData
/// </summary>
public string? RemainingArgumentsPropertyType { get; set; }

/// <summary>
/// Whether the RemainingArguments property is an array type (vs IReadOnlyList, IEnumerable, etc).
/// </summary>
public bool RemainingArgumentsIsArray { get; set; }

/// <summary>
/// Whether any special properties exist.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.Collections.Generic;
using McMaster.Extensions.CommandLineUtils.SourceGeneration;
using Xunit;

namespace McMaster.Extensions.CommandLineUtils.Tests.SourceGeneration
{
/// <summary>
/// Tests that simulate patterns used in generated model factories.
/// These tests verify the correctness of code generation patterns.
/// </summary>
public class GeneratedModelFactoryTests
{
#region Test Models

private class ModelWithValueTypeConstructorParameters
{
public int Port { get; }
public bool Verbose { get; }

public ModelWithValueTypeConstructorParameters(int port, bool verbose)
{
Port = port;
Verbose = verbose;
}
}

private class ModelWithMixedConstructorParameters
{
public string Name { get; }
public int Count { get; }

public ModelWithMixedConstructorParameters(string name, int count)
{
Name = name;
Count = count;
}
}

#endregion

#region Test Service Provider

private class TestServiceProvider : IServiceProvider
{
private readonly Dictionary<Type, object?> _services = new();

public void Register<T>(T service)
{
_services[typeof(T)] = service;
}

public object? GetService(Type serviceType)
{
_services.TryGetValue(serviceType, out var service);
return service;
}
}

#endregion

#region Bug Documentation: 'as' operator with value types

/// <summary>
/// Documents the bug in generated code: using 'as' with non-nullable value types doesn't compile.
///
/// The current generator produces:
/// var p0_0 = _services.GetService(typeof(int)) as int;
///
/// This is invalid C# because 'as' can only be used with reference types or nullable value types.
/// The generated code will FAIL TO COMPILE for any command with value type constructor parameters.
/// </summary>
[Fact]
public void Bug_AsOperatorWithNonNullableValueType_DoesNotCompile()
{
// This test documents the compilation failure.
// We can't write code that fails to compile in a test, so we demonstrate
// the issue conceptually:

var services = new TestServiceProvider();
services.Register<int>(8080);

var service = services.GetService(typeof(int));

// THIS IS THE BUG: The generator produces this pattern which doesn't compile:
// var p0_0 = service as int; // ERROR CS0077: The 'as' operator must be used with a reference type or nullable value type

// To make it compile, we'd need to use nullable:
var p0_0_nullable = service as int?; // This compiles but defeats the purpose

// Or better yet, use the correct pattern (check then cast):
var p0_0_correct = service != null ? (int)service : default;

Assert.NotNull(p0_0_nullable);
Assert.Equal(8080, p0_0_nullable.Value);
Assert.Equal(8080, p0_0_correct);
}

#endregion

#region Correct Pattern: Check service then cast

/// <summary>
/// This factory simulates the CORRECT pattern that the generator should produce:
/// check if service exists, then cast using direct cast operator.
/// </summary>
private class CorrectGeneratedFactory_CheckThenCast : IModelFactory<ModelWithValueTypeConstructorParameters>
{
private readonly IServiceProvider? _services;

public CorrectGeneratedFactory_CheckThenCast(IServiceProvider? services) => _services = services;

public ModelWithValueTypeConstructorParameters Create()
{
if (_services != null)
{
var instance = _services.GetService(typeof(ModelWithValueTypeConstructorParameters)) as ModelWithValueTypeConstructorParameters;
if (instance != null) return instance;

// CORRECT PATTERN: Check service object, then cast
var service0_0 = _services.GetService(typeof(int));
var service0_1 = _services.GetService(typeof(bool));

if (service0_0 != null && service0_1 != null)
{
return new ModelWithValueTypeConstructorParameters((int)service0_0, (bool)service0_1);
}
}

throw new InvalidOperationException("Unable to create instance of ModelWithValueTypeConstructorParameters");
}

object IModelFactory.Create() => Create();
}

[Fact]
public void CorrectPattern_CheckThenCast_WorksForValueTypeConstructorParameters()
{
// Arrange
var services = new TestServiceProvider();
services.Register<int>(8080);
services.Register<bool>(true);

var factory = new CorrectGeneratedFactory_CheckThenCast(services);

// Act
var instance = factory.Create();

// Assert
Assert.NotNull(instance);
Assert.Equal(8080, instance.Port);
Assert.True(instance.Verbose);
}

[Fact]
public void CorrectPattern_CheckThenCast_WorksForMixedReferenceAndValueTypes()
{
// Arrange
var services = new TestServiceProvider();
services.Register<string>("test-name");
services.Register<int>(42);

var factory = new CorrectGeneratedFactory_MixedTypes(services);

// Act
var instance = factory.Create();

// Assert
Assert.NotNull(instance);
Assert.Equal("test-name", instance.Name);
Assert.Equal(42, instance.Count);
}

private class CorrectGeneratedFactory_MixedTypes : IModelFactory<ModelWithMixedConstructorParameters>
{
private readonly IServiceProvider? _services;

public CorrectGeneratedFactory_MixedTypes(IServiceProvider? services) => _services = services;

public ModelWithMixedConstructorParameters Create()
{
if (_services != null)
{
var instance = _services.GetService(typeof(ModelWithMixedConstructorParameters)) as ModelWithMixedConstructorParameters;
if (instance != null) return instance;

var service0_0 = _services.GetService(typeof(string));
var service0_1 = _services.GetService(typeof(int));

if (service0_0 != null && service0_1 != null)
{
return new ModelWithMixedConstructorParameters((string)service0_0, (int)service0_1);
}
}

throw new InvalidOperationException("Unable to create instance");
}

object IModelFactory.Create() => Create();
}

#endregion

#region Edge Cases

[Fact]
public void CorrectPattern_FailsGracefully_WhenServiceNotRegistered()
{
// Arrange: Don't register the services
var services = new TestServiceProvider();

var factory = new CorrectGeneratedFactory_CheckThenCast(services);

// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => factory.Create());
Assert.Contains("Unable to create instance", ex.Message);
}

[Fact]
public void CorrectPattern_FailsGracefully_WhenPartialServicesRegistered()
{
// Arrange: Only register one of two required services
var services = new TestServiceProvider();
services.Register<int>(8080);
// bool not registered

var factory = new CorrectGeneratedFactory_CheckThenCast(services);

// Act & Assert
var ex = Assert.Throws<InvalidOperationException>(() => factory.Create());
Assert.Contains("Unable to create instance", ex.Message);
}

#endregion
}
}
Loading
Loading