44 Commits

Author SHA1 Message Date
Tyrrrz
ca8368e1d9 asd 2025-09-11 20:54:14 +03:00
Tyrrrz
d4b6a2b7a9 asd 2025-09-11 20:44:13 +03:00
dependabot[bot]
63f57025e8 Bump the nuget group with 5 updates (#163)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-04 14:13:09 +03:00
Tyrrrz
25083eff44 asd 2025-07-17 22:12:53 +03:00
dependabot[bot]
7f2c00fe3a Bump the nuget group with 7 updates (#162)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-01 18:58:48 +03:00
Tyrrrz
7638b997ff Reorganize dependency conditions 2025-06-08 21:15:32 +03:00
dependabot[bot]
d80d012938 Bump Basic.Reference.Assemblies.Net80 and 5 others (#160)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-02 01:41:55 +03:00
Oleksii Holub
2a02d39dba Allow user-defined options to shadow implicit ones (--help, --version) (#159) 2025-05-12 22:53:08 +03:00
dependabot[bot]
c40b4f3501 Bump Microsoft.Extensions.DependencyInjection in the nuget group (#158)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-03 23:23:58 +03:00
dependabot[bot]
3fb2a2319b Bump the nuget group with 3 updates (#155)
Bumps the nuget group with 3 updates: [FluentAssertions](https://github.com/fluentassertions/fluentassertions), [Microsoft.Extensions.DependencyInjection](https://github.com/dotnet/runtime) and [CliWrap](https://github.com/Tyrrrz/CliWrap).


Updates `FluentAssertions` from 8.1.1 to 8.2.0
- [Release notes](https://github.com/fluentassertions/fluentassertions/releases)
- [Changelog](https://github.com/fluentassertions/fluentassertions/blob/main/AcceptApiChanges.ps1)
- [Commits](https://github.com/fluentassertions/fluentassertions/compare/8.1.1...8.2.0)

Updates `Microsoft.Extensions.DependencyInjection` from 9.0.2 to 9.0.3
- [Release notes](https://github.com/dotnet/runtime/releases)
- [Commits](https://github.com/dotnet/runtime/compare/v9.0.2...v9.0.3)

Updates `CliWrap` from 3.8.1 to 3.8.2
- [Release notes](https://github.com/Tyrrrz/CliWrap/releases)
- [Commits](https://github.com/Tyrrrz/CliWrap/compare/3.8.1...3.8.2)

---
updated-dependencies:
- dependency-name: FluentAssertions
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: nuget
- dependency-name: Microsoft.Extensions.DependencyInjection
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
- dependency-name: CliWrap
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: nuget
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 18:35:54 +03:00
dependabot[bot]
1a5a0374c7 Bump the nuget group with 6 updates (#153)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-03 21:47:10 +02:00
Tyrrrz
432c8a66af Merge master 2025-02-02 21:50:01 +02:00
Tyrrrz
078ddeaf07 Handle breaking changes 2025-02-02 21:49:30 +02:00
Tyrrrz
0fa2ebc636 asd 2025-02-02 21:40:41 +02:00
dependabot[bot]
c79a8c6502 Bump the nuget group with 8 updates (#152)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-02 21:36:15 +02:00
Tyrrrz
357426c536 Merge branch 'master' into aot 2024-11-12 22:47:34 +02:00
Tyrrrz
20481d4e24 asd 2024-11-12 22:42:55 +02:00
Tyrrrz
2cb9335e25 merge 2024-11-12 22:41:04 +02:00
Tyrrrz
40beb283d5 asd 2024-09-21 21:03:05 +03:00
Tyrrrz
71fe231f28 asdad 2024-09-19 22:41:12 +03:00
Tyrrrz
8546c54c23 wip as fuck 2024-09-13 02:00:39 +03:00
Tyrrrz
0fc88a42ba asd 2024-09-03 03:39:15 +03:00
Tyrrrz
cb8f4b122e asd 2024-09-03 02:04:52 +03:00
Tyrrrz
540f307f42 asd 2024-09-03 02:04:04 +03:00
Tyrrrz
a62ce71424 asd 2024-09-03 02:03:50 +03:00
Tyrrrz
0532d724a1 asd 2024-08-15 03:02:44 +03:00
Tyrrrz
545c7c3fbd asd 2024-08-12 03:36:14 +03:00
Tyrrrz
a813436577 asd 2024-08-12 03:35:46 +03:00
Tyrrrz
fcc93603a7 asd 2024-08-11 22:39:13 +03:00
Tyrrrz
2d3c221b48 asd 2024-08-11 22:34:50 +03:00
Tyrrrz
651146c97b asd 2024-08-11 22:22:03 +03:00
Tyrrrz
82b0c6fd98 asd 2024-08-11 22:12:36 +03:00
Tyrrrz
a4376c955b asd 2024-08-11 03:58:59 +03:00
Tyrrrz
f7645afbdb asd 2024-08-11 01:44:40 +03:00
Tyrrrz
e20672328b asd 2024-08-06 01:42:09 +03:00
Tyrrrz
3e7eb08eca asd 2024-06-16 22:20:16 +03:00
Tyrrrz
cfd28c133e asd 2024-06-16 21:09:42 +03:00
Tyrrrz
034d3cec66 asd 2024-06-16 02:16:43 +03:00
Tyrrrz
3fc7054f80 asd 2024-06-16 01:31:34 +03:00
Tyrrrz
2323a57c39 asd 2024-06-15 21:19:44 +03:00
Tyrrrz
24fd87b1e1 asd 2024-05-31 18:49:20 +03:00
Tyrrrz
cad1c14474 asd 2024-05-28 21:20:09 +03:00
Tyrrrz
57db910489 Merge branch 'master' into aot 2024-05-27 20:57:50 +03:00
Tyrrrz
ae9c4e6d1e Enable analyzers 2024-05-27 20:57:37 +03:00
156 changed files with 2470 additions and 5979 deletions

View File

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Basic.Reference.Assemblies.Net80" Version="1.7.9" />
<PackageReference Include="coverlet.collector" Version="6.0.3" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="7.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" />
<ProjectReference Include="..\CliFx\CliFx.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,75 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class CommandMustBeAnnotatedAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } = new CommandMustBeAnnotatedAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_command_is_not_annotated_with_the_command_attribute()
{
// Arrange
// lang=csharp
const string code = """
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_command_is_annotated_with_the_command_attribute()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public abstract class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_command_is_implemented_as_an_abstract_class()
{
// Arrange
// lang=csharp
const string code = """
public abstract class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command()
{
// Arrange
// lang=csharp
const string code = """
public class Foo
{
public int Bar { get; init; } = 5;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,61 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class CommandMustImplementInterfaceAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new CommandMustImplementInterfaceAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_command_does_not_implement_ICommand_interface()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_command_implements_ICommand_interface()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_class_that_is_not_a_command()
{
// Arrange
// lang=csharp
const string code = """
public class Foo
{
public int Bar { get; init; } = 5;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,29 +0,0 @@
using System;
using System.Linq;
using FluentAssertions;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class GeneralSpecs
{
[Fact]
public void All_analyzers_have_unique_diagnostic_IDs()
{
// Arrange
var analyzers = typeof(AnalyzerBase)
.Assembly.GetTypes()
.Where(t => !t.IsAbstract && t.IsAssignableTo(typeof(DiagnosticAnalyzer)))
.Select(t => (DiagnosticAnalyzer)Activator.CreateInstance(t)!)
.ToArray();
// Act
var diagnosticIds = analyzers
.SelectMany(a => a.SupportedDiagnostics.Select(d => d.Id))
.ToArray();
// Assert
diagnosticIds.Should().OnlyHaveUniqueItems();
}
}

View File

@@ -1,83 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustBeInsideCommandAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustBeInsideCommandAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_is_inside_a_class_that_is_not_a_command()
{
// Arrange
// lang=csharp
const string code = """
public class MyClass
{
[CommandOption("foo")]
public string? Foo { get; init; }
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_is_inside_a_command()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_is_inside_an_abstract_class()
{
// Arrange
// lang=csharp
const string code = """
public abstract class MyCommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,110 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustBeRequiredIfPropertyRequiredAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustBeRequiredIfPropertyRequiredAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_non_required_option_is_bound_to_a_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f', IsRequired = false)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_required_option_is_bound_to_a_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_required_option_is_bound_to_a_non_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f', IsRequired = false)]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_required_option_is_bound_to_a_non_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,90 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveNameOrShortNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustHaveNameOrShortNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_does_not_have_a_name_or_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption(null)]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,95 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveUniqueNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveUniqueNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_the_same_name_as_another_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
[CommandOption("foo")]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
[CommandOption("bar")]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,119 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveUniqueShortNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustHaveUniqueShortNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_the_same_short_name_as_another_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
[CommandOption('f')]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_unique_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
[CommandOption('b')]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_short_name_which_is_unique_only_in_casing()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
[CommandOption('F')]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,175 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveValidConverterAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustHaveValidConverterAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_converter_that_does_not_derive_from_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter
{
public string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_converter_that_does_not_derive_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<int>
{
public override int Convert(string? rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<string>
{
public override string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_nullable_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<int>
{
public override int Convert(string? rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public int? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_scalar_option_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<string>
{
public override string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public IReadOnlyList<string>? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_converter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,109 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveValidNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } = new OptionMustHaveValidNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_name_which_is_too_short()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("f")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_name_that_starts_with_a_non_letter_character()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("1foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,90 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveValidShortNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustHaveValidShortNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_short_name_which_is_not_a_letter_character()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('1')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_a_valid_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption('f')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_a_short_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,125 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class OptionMustHaveValidValidatorsAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new OptionMustHaveValidValidatorsAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_validator_that_does_not_derive_from_BindingValidator()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator
{
public void Validate(string value) {}
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Validators = new[] { typeof(MyValidator) })]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_an_option_has_a_validator_that_does_not_derive_from_a_compatible_BindingValidator()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator : BindingValidator<int>
{
public override BindingValidationError Validate(int value) => Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Validators = new[] { typeof(MyValidator) })]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_has_validators_that_all_derive_from_compatible_BindingValidators()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator : BindingValidator<string>
{
public override BindingValidationError Validate(string value) => Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Validators = new[] { typeof(MyValidator) })]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_an_option_does_not_have_validators()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_an_option()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,84 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeInsideCommandAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeInsideCommandAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_is_inside_a_class_that_is_not_a_command()
{
// Arrange
// lang=csharp
const string code = """
public class MyClass
{
[CommandParameter(0)]
public required string Foo { get; init; }
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_a_command()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_is_inside_an_abstract_class()
{
// Arrange
// lang=csharp
const string code = """
public abstract class MyCommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,99 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeLastIfNonRequiredAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeLastIfNonRequiredAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_non_required_parameter_is_not_the_last_in_order()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, IsRequired = false)]
public string? Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_required_parameter_is_the_last_in_order()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1, IsRequired = false)]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_no_non_required_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,99 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeLastIfNonScalarAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeLastIfNonScalarAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_non_scalar_parameter_is_not_the_last_in_order()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string[] Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_scalar_parameter_is_the_last_in_order()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string[] Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,110 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeRequiredIfPropertyRequiredAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeRequiredIfPropertyRequiredAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_non_required_parameter_is_bound_to_a_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, IsRequired = false)]
public required string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_required_parameter_is_bound_to_a_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_required_parameter_is_bound_to_a_non_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, IsRequired = false)]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_required_parameter_is_bound_to_a_non_required_property()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,99 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeSingleIfNonRequiredAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeSingleIfNonRequiredAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_more_than_one_non_required_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, IsRequired = false)]
public string? Foo { get; init; }
[CommandParameter(1, IsRequired = false)]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_only_one_non_required_parameter_is_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1, IsRequired = false)]
public string? Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_no_non_required_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,99 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustBeSingleIfNonScalarAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustBeSingleIfNonScalarAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_more_than_one_non_scalar_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string[] Foo { get; init; }
[CommandParameter(1)]
public required string[] Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_only_one_non_scalar_parameter_is_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string[] Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_no_non_scalar_parameters_are_defined()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,75 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustHaveUniqueNameAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } = new ParameterMustHaveUniqueNameAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_has_the_same_name_as_another_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Name = "foo")]
public required string Foo { get; init; }
[CommandParameter(1, Name = "foo")]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_name()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Name = "foo")]
public required string Foo { get; init; }
[CommandParameter(1, Name = "bar")]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,76 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustHaveUniqueOrderAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustHaveUniqueOrderAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_has_the_same_order_as_another_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(0)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_has_unique_order()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
[CommandParameter(1)]
public required string Bar { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,175 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustHaveValidConverterAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustHaveValidConverterAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_has_a_converter_that_does_not_derive_from_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter
{
public string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_has_a_converter_that_does_not_derive_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<int>
{
public override int Convert(string? rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<string>
{
public override string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_nullable_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<int>
{
public override int Convert(string? rawValue) => 42;
}
[Command]
public class MyCommand : ICommand
{
[CommandOption("foo", Converter = typeof(MyConverter))]
public int? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_non_scalar_parameter_has_a_converter_that_derives_from_a_compatible_BindingConverter()
{
// Arrange
// lang=csharp
const string code = """
public class MyConverter : BindingConverter<string>
{
public override string Convert(string? rawValue) => rawValue;
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Converter = typeof(MyConverter))]
public required IReadOnlyList<string> Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_a_converter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,125 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class ParameterMustHaveValidValidatorsAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new ParameterMustHaveValidValidatorsAnalyzer();
[Fact]
public void Analyzer_reports_an_error_a_parameter_has_a_validator_that_does_not_derive_from_BindingValidator()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator
{
public void Validate(string value) {}
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Validators = new[] { typeof(MyValidator) })]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_a_parameter_has_a_validator_that_does_not_derive_from_a_compatible_BindingValidator()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator : BindingValidator<int>
{
public override BindingValidationError Validate(int value) => Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Validators = new[] { typeof(MyValidator) })]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_has_validators_that_all_derive_from_compatible_BindingValidators()
{
// Arrange
// lang=csharp
const string code = """
public class MyValidator : BindingValidator<string>
{
public override BindingValidationError Validate(string value) => Ok();
}
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0, Validators = new[] { typeof(MyValidator) })]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_parameter_does_not_have_validators()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
[CommandParameter(0)]
public required string Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_on_a_property_that_is_not_a_parameter()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,134 +0,0 @@
using CliFx.Analyzers.Tests.Utils;
using Microsoft.CodeAnalysis.Diagnostics;
using Xunit;
namespace CliFx.Analyzers.Tests;
public class SystemConsoleShouldBeAvoidedAnalyzerSpecs
{
private static DiagnosticAnalyzer Analyzer { get; } =
new SystemConsoleShouldBeAvoidedAnalyzer();
[Fact]
public void Analyzer_reports_an_error_if_a_command_calls_a_method_on_SystemConsole()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
Console.WriteLine("Hello world");
return default;
}
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_a_command_accesses_a_property_on_SystemConsole()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
Console.ForegroundColor = ConsoleColor.Black;
return default;
}
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_reports_an_error_if_a_command_calls_a_method_on_a_property_of_SystemConsole()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
Console.Error.WriteLine("Hello world");
return default;
}
}
""";
// Act & assert
Analyzer.Should().ProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_command_interacts_with_the_console_through_IConsole()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
console.WriteLine("Hello world");
return default;
}
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_IConsole_is_not_available_in_the_current_method()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public void SomeOtherMethod() => Console.WriteLine("Test");
public ValueTask ExecuteAsync(IConsole console) => default;
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
[Fact]
public void Analyzer_does_not_report_an_error_if_a_command_does_not_access_SystemConsole()
{
// Arrange
// lang=csharp
const string code = """
[Command]
public class MyCommand : ICommand
{
public ValueTask ExecuteAsync(IConsole console)
{
return default;
}
}
""";
// Act & assert
Analyzer.Should().NotProduceDiagnostics(code);
}
}

View File

@@ -1,174 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Basic.Reference.Assemblies;
using FluentAssertions.Execution;
using FluentAssertions.Primitives;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;
namespace CliFx.Analyzers.Tests.Utils;
internal class AnalyzerAssertions(DiagnosticAnalyzer analyzer)
: ReferenceTypeAssertions<DiagnosticAnalyzer, AnalyzerAssertions>(analyzer)
{
protected override string Identifier => "analyzer";
private Compilation Compile(string sourceCode)
{
// Get default system namespaces
var defaultSystemNamespaces = new[]
{
"System",
"System.Collections.Generic",
"System.Threading.Tasks",
};
// Get default CliFx namespaces
var defaultCliFxNamespaces = typeof(ICommand)
.Assembly.GetTypes()
.Where(t => t.IsPublic)
.Select(t => t.Namespace)
.Distinct()
.ToArray();
// Append default imports to the source code
var sourceCodeWithUsings =
string.Join(Environment.NewLine, defaultSystemNamespaces.Select(n => $"using {n};"))
+ string.Join(Environment.NewLine, defaultCliFxNamespaces.Select(n => $"using {n};"))
+ Environment.NewLine
+ sourceCode;
// Parse the source code
var ast = SyntaxFactory.ParseSyntaxTree(
SourceText.From(sourceCodeWithUsings),
CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Preview)
);
// Compile the code to IL
var compilation = CSharpCompilation.Create(
"CliFxTests_DynamicAssembly_" + Guid.NewGuid(),
[ast],
Net80.References.All.Append(
MetadataReference.CreateFromFile(typeof(ICommand).Assembly.Location)
),
// DLL to avoid having to define the Main() method
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)
);
var compilationErrors = compilation
.GetDiagnostics()
.Where(d => d.Severity >= DiagnosticSeverity.Error)
.ToArray();
if (compilationErrors.Any())
{
throw new InvalidOperationException(
$"""
Failed to compile code.
{string.Join(Environment.NewLine, compilationErrors.Select(e => e.ToString()))}
"""
);
}
return compilation;
}
private IReadOnlyList<Diagnostic> GetProducedDiagnostics(string sourceCode)
{
var analyzers = ImmutableArray.Create(Subject);
var compilation = Compile(sourceCode);
return compilation
.WithAnalyzers(analyzers)
.GetAnalyzerDiagnosticsAsync(analyzers, default)
.GetAwaiter()
.GetResult();
}
public void ProduceDiagnostics(string sourceCode)
{
var expectedDiagnostics = Subject.SupportedDiagnostics;
var producedDiagnostics = GetProducedDiagnostics(sourceCode);
var expectedDiagnosticIds = expectedDiagnostics.Select(d => d.Id).Distinct().ToArray();
var producedDiagnosticIds = producedDiagnostics.Select(d => d.Id).Distinct().ToArray();
var isSuccessfulAssertion =
expectedDiagnosticIds.Intersect(producedDiagnosticIds).Count()
== expectedDiagnosticIds.Length;
Execute
.Assertion.ForCondition(isSuccessfulAssertion)
.FailWith(() =>
{
var buffer = new StringBuilder();
buffer.AppendLine("Expected and produced diagnostics do not match.");
buffer.AppendLine();
buffer.AppendLine("Expected diagnostics:");
foreach (var expectedDiagnostic in expectedDiagnostics)
{
buffer.Append(" - ");
buffer.Append(expectedDiagnostic.Id);
buffer.AppendLine();
}
buffer.AppendLine();
buffer.AppendLine("Produced diagnostics:");
if (producedDiagnostics.Any())
{
foreach (var producedDiagnostic in producedDiagnostics)
{
buffer.Append(" - ");
buffer.Append(producedDiagnostic);
}
}
else
{
buffer.AppendLine(" < none >");
}
return new FailReason(buffer.ToString());
});
}
public void NotProduceDiagnostics(string sourceCode)
{
var producedDiagnostics = GetProducedDiagnostics(sourceCode);
var isSuccessfulAssertion = !producedDiagnostics.Any();
Execute
.Assertion.ForCondition(isSuccessfulAssertion)
.FailWith(() =>
{
var buffer = new StringBuilder();
buffer.AppendLine("Expected no produced diagnostics.");
buffer.AppendLine();
buffer.AppendLine("Produced diagnostics:");
foreach (var producedDiagnostic in producedDiagnostics)
{
buffer.Append(" - ");
buffer.Append(producedDiagnostic);
}
return new FailReason(buffer.ToString());
});
}
}
internal static class AnalyzerAssertionsExtensions
{
public static AnalyzerAssertions Should(this DiagnosticAnalyzer analyzer) => new(analyzer);
}

View File

@@ -1,5 +0,0 @@
{
"$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
"methodDisplayOptions": "all",
"methodDisplay": "method"
}

View File

@@ -1,40 +0,0 @@
using System.Collections.Immutable;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
public abstract class AnalyzerBase : DiagnosticAnalyzer
{
public DiagnosticDescriptor SupportedDiagnostic { get; }
public sealed override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get; }
protected AnalyzerBase(
string diagnosticTitle,
string diagnosticMessage,
DiagnosticSeverity diagnosticSeverity = DiagnosticSeverity.Error
)
{
SupportedDiagnostic = new DiagnosticDescriptor(
"CliFx_" + GetType().Name.TrimEnd("Analyzer"),
diagnosticTitle,
diagnosticMessage,
"CliFx",
diagnosticSeverity,
true
);
SupportedDiagnostics = ImmutableArray.Create(SupportedDiagnostic);
}
protected Diagnostic CreateDiagnostic(Location location, params object?[]? messageArgs) =>
Diagnostic.Create(SupportedDiagnostic, location, messageArgs);
public override void Initialize(AnalysisContext context)
{
context.EnableConcurrentExecution();
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
}
}

View File

@@ -1,50 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class CommandMustBeAnnotatedAnalyzer()
: AnalyzerBase(
$"Commands must be annotated with `{SymbolNames.CliFxCommandAttribute}`",
$"This type must be annotated with `{SymbolNames.CliFxCommandAttribute}` in order to be a valid command."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
ClassDeclarationSyntax classDeclaration,
ITypeSymbol type
)
{
// Ignore abstract classes, because they may be used to define
// base implementations for commands, in which case the command
// attribute doesn't make sense.
if (type.IsAbstract)
return;
var implementsCommandInterface = type.AllInterfaces.Any(i =>
i.DisplayNameMatches(SymbolNames.CliFxCommandInterface)
);
var hasCommandAttribute = type.GetAttributes()
.Select(a => a.AttributeClass)
.Any(c => c.DisplayNameMatches(SymbolNames.CliFxCommandAttribute));
// If the interface is implemented, but the attribute is missing,
// then it's very likely a user error.
if (implementsCommandInterface && !hasCommandAttribute)
{
context.ReportDiagnostic(CreateDiagnostic(classDeclaration.Identifier.GetLocation()));
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandleClassDeclaration(Analyze);
}
}

View File

@@ -1,44 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class CommandMustImplementInterfaceAnalyzer()
: AnalyzerBase(
$"Commands must implement `{SymbolNames.CliFxCommandInterface}` interface",
$"This type must implement `{SymbolNames.CliFxCommandInterface}` interface in order to be a valid command."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
ClassDeclarationSyntax classDeclaration,
ITypeSymbol type
)
{
var hasCommandAttribute = type.GetAttributes()
.Select(a => a.AttributeClass)
.Any(c => c.DisplayNameMatches(SymbolNames.CliFxCommandAttribute));
var implementsCommandInterface = type.AllInterfaces.Any(i =>
i.DisplayNameMatches(SymbolNames.CliFxCommandInterface)
);
// If the attribute is present, but the interface is not implemented,
// it's very likely a user error.
if (hasCommandAttribute && !implementsCommandInterface)
{
context.ReportDiagnostic(CreateDiagnostic(classDeclaration.Identifier.GetLocation()));
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandleClassDeclaration(Analyze);
}
}

View File

@@ -1,89 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel;
internal partial class CommandOptionSymbol(
IPropertySymbol property,
string? name,
char? shortName,
bool? isRequired,
ITypeSymbol? converterType,
IReadOnlyList<ITypeSymbol> validatorTypes
) : ICommandMemberSymbol
{
public IPropertySymbol Property { get; } = property;
public string? Name { get; } = name;
public char? ShortName { get; } = shortName;
public bool? IsRequired { get; } = isRequired;
public ITypeSymbol? ConverterType { get; } = converterType;
public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; } = validatorTypes;
}
internal partial class CommandOptionSymbol
{
private static AttributeData? TryGetOptionAttribute(IPropertySymbol property) =>
property
.GetAttributes()
.FirstOrDefault(a =>
a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandOptionAttribute)
== true
);
public static CommandOptionSymbol? TryResolve(IPropertySymbol property)
{
var attribute = TryGetOptionAttribute(property);
if (attribute is null)
return null;
var name =
attribute
.ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_String)
.Select(a => a.Value)
.FirstOrDefault() as string;
var shortName =
attribute
.ConstructorArguments.Where(a => a.Type?.SpecialType == SpecialType.System_Char)
.Select(a => a.Value)
.FirstOrDefault() as char?;
var isRequired =
attribute
.NamedArguments.Where(a => a.Key == "IsRequired")
.Select(a => a.Value.Value)
.FirstOrDefault() as bool?;
var converter = attribute
.NamedArguments.Where(a => a.Key == "Converter")
.Select(a => a.Value.Value)
.Cast<ITypeSymbol?>()
.FirstOrDefault();
var validators = attribute
.NamedArguments.Where(a => a.Key == "Validators")
.SelectMany(a => a.Value.Values)
.Select(c => c.Value)
.Cast<ITypeSymbol>()
.ToArray();
return new CommandOptionSymbol(
property,
name,
shortName,
isRequired,
converter,
validators
);
}
public static bool IsOptionProperty(IPropertySymbol property) =>
TryGetOptionAttribute(property) is not null;
}

View File

@@ -1,78 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel;
internal partial class CommandParameterSymbol(
IPropertySymbol property,
int order,
string? name,
bool? isRequired,
ITypeSymbol? converterType,
IReadOnlyList<ITypeSymbol> validatorTypes
) : ICommandMemberSymbol
{
public IPropertySymbol Property { get; } = property;
public int Order { get; } = order;
public string? Name { get; } = name;
public bool? IsRequired { get; } = isRequired;
public ITypeSymbol? ConverterType { get; } = converterType;
public IReadOnlyList<ITypeSymbol> ValidatorTypes { get; } = validatorTypes;
}
internal partial class CommandParameterSymbol
{
private static AttributeData? TryGetParameterAttribute(IPropertySymbol property) =>
property
.GetAttributes()
.FirstOrDefault(a =>
a.AttributeClass?.DisplayNameMatches(SymbolNames.CliFxCommandParameterAttribute)
== true
);
public static CommandParameterSymbol? TryResolve(IPropertySymbol property)
{
var attribute = TryGetParameterAttribute(property);
if (attribute is null)
return null;
var order = (int)attribute.ConstructorArguments.Select(a => a.Value).First()!;
var name =
attribute
.NamedArguments.Where(a => a.Key == "Name")
.Select(a => a.Value.Value)
.FirstOrDefault() as string;
var isRequired =
attribute
.NamedArguments.Where(a => a.Key == "IsRequired")
.Select(a => a.Value.Value)
.FirstOrDefault() as bool?;
var converter = attribute
.NamedArguments.Where(a => a.Key == "Converter")
.Select(a => a.Value.Value)
.Cast<ITypeSymbol?>()
.FirstOrDefault();
var validators = attribute
.NamedArguments.Where(a => a.Key == "Validators")
.SelectMany(a => a.Value.Values)
.Select(c => c.Value)
.Cast<ITypeSymbol>()
.ToArray();
return new CommandParameterSymbol(property, order, name, isRequired, converter, validators);
}
public static bool IsParameterProperty(IPropertySymbol property) =>
TryGetParameterAttribute(property) is not null;
}

View File

@@ -1,21 +0,0 @@
using System.Collections.Generic;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.Analyzers.ObjectModel;
internal interface ICommandMemberSymbol
{
IPropertySymbol Property { get; }
ITypeSymbol? ConverterType { get; }
IReadOnlyList<ITypeSymbol> ValidatorTypes { get; }
}
internal static class CommandMemberSymbolExtensions
{
public static bool IsScalar(this ICommandMemberSymbol member) =>
member.Property.Type.SpecialType == SpecialType.System_String
|| member.Property.Type.TryGetEnumerableUnderlyingType() is null;
}

View File

@@ -1,49 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustBeInsideCommandAnalyzer()
: AnalyzerBase(
"Options must be defined inside commands",
$"This option must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
if (property.ContainingType.IsAbstract)
return;
if (!CommandOptionSymbol.IsOptionProperty(property))
return;
var isInsideCommand = property.ContainingType.AllInterfaces.Any(i =>
i.DisplayNameMatches(SymbolNames.CliFxCommandInterface)
);
if (!isInsideCommand)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,43 +0,0 @@
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustBeRequiredIfPropertyRequiredAnalyzer()
: AnalyzerBase(
"Options bound to required properties cannot be marked as non-required",
"This option cannot be marked as non-required because it's bound to a required property."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
if (!property.IsRequired())
return;
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (option.IsRequired != false)
return;
context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()));
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,39 +0,0 @@
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveNameOrShortNameAnalyzer()
: AnalyzerBase(
"Options must have either a name or short name specified",
"This option must have either a name or short name specified."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (string.IsNullOrWhiteSpace(option.Name) && option.ShortName is null)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,69 +0,0 @@
using System;
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveUniqueNameAnalyzer()
: AnalyzerBase(
"Options must have unique names",
"This option's name must be unique within the command (comparison IS NOT case sensitive). "
+ "Specified name: `{0}`. "
+ "Property bound to another option with the same name: `{1}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (string.IsNullOrWhiteSpace(option.Name))
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherOption = CommandOptionSymbol.TryResolve(otherProperty);
if (otherOption is null)
continue;
if (string.IsNullOrWhiteSpace(otherOption.Name))
continue;
if (string.Equals(option.Name, otherOption.Name, StringComparison.OrdinalIgnoreCase))
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
option.Name,
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,68 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveUniqueShortNameAnalyzer()
: AnalyzerBase(
"Options must have unique short names",
"This option's short name must be unique within the command (comparison IS case sensitive). "
+ "Specified short name: `{0}` "
+ "Property bound to another option with the same short name: `{1}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (option.ShortName is null)
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherOption = CommandOptionSymbol.TryResolve(otherProperty);
if (otherOption is null)
continue;
if (otherOption.ShortName is null)
continue;
if (option.ShortName == otherOption.ShortName)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
option.ShortName,
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,65 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveValidConverterAnalyzer()
: AnalyzerBase(
$"Option converters must derive from `{SymbolNames.CliFxBindingConverterClass}`",
$"Converter specified for this option must derive from a compatible `{SymbolNames.CliFxBindingConverterClass}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (option.ConverterType is null)
return;
var converterValueType = option
.ConverterType.GetBaseTypes()
.FirstOrDefault(t =>
t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingConverterClass)
)
?.TypeArguments.FirstOrDefault();
// Value returned by the converter must be assignable to the property type
var isCompatible =
converterValueType is not null
&& (
option.IsScalar()
// Scalar
? context.Compilation.IsAssignable(converterValueType, property.Type)
// Non-scalar (assume we can handle all IEnumerable types for simplicity)
: property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType
&& context.Compilation.IsAssignable(
converterValueType,
enumerableUnderlyingType
)
);
if (!isCompatible)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,43 +0,0 @@
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveValidNameAnalyzer()
: AnalyzerBase(
"Options must have valid names",
"This option's name must be at least 2 characters long and must start with a letter. "
+ "Specified name: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (string.IsNullOrWhiteSpace(option.Name))
return;
if (option.Name.Length < 2 || !char.IsLetter(option.Name[0]))
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation(), option.Name)
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,43 +0,0 @@
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveValidShortNameAnalyzer()
: AnalyzerBase(
"Option short names must be letter characters",
"This option's short name must be a single letter character. "
+ "Specified short name: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
if (option.ShortName is null)
return;
if (!char.IsLetter(option.ShortName.Value))
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation(), option.ShortName)
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,58 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class OptionMustHaveValidValidatorsAnalyzer()
: AnalyzerBase(
$"Option validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`",
$"Each validator specified for this option must derive from a compatible `{SymbolNames.CliFxBindingValidatorClass}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var option = CommandOptionSymbol.TryResolve(property);
if (option is null)
return;
foreach (var validatorType in option.ValidatorTypes)
{
var validatorValueType = validatorType
.GetBaseTypes()
.FirstOrDefault(t =>
t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingValidatorClass)
)
?.TypeArguments.FirstOrDefault();
// Value passed to the validator must be assignable from the property type
var isCompatible =
validatorValueType is not null
&& context.Compilation.IsAssignable(property.Type, validatorValueType);
if (!isCompatible)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
// No need to report multiple identical diagnostics on the same node
break;
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,49 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeInsideCommandAnalyzer()
: AnalyzerBase(
"Parameters must be defined inside commands",
$"This parameter must be defined inside a class that implements `{SymbolNames.CliFxCommandInterface}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
if (property.ContainingType.IsAbstract)
return;
if (!CommandParameterSymbol.IsParameterProperty(property))
return;
var isInsideCommand = property.ContainingType.AllInterfaces.Any(i =>
i.DisplayNameMatches(SymbolNames.CliFxCommandInterface)
);
if (!isInsideCommand)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,63 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeLastIfNonRequiredAnalyzer()
: AnalyzerBase(
"Parameters marked as non-required must be the last in order",
"This parameter is non-required so it must be the last in order (its order must be highest within the command). "
+ "Property bound to another non-required parameter: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.IsRequired != false)
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (otherParameter.Order > parameter.Order)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,63 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeLastIfNonScalarAnalyzer()
: AnalyzerBase(
"Parameters of non-scalar types must be the last in order",
"This parameter has a non-scalar type so it must be the last in order (its order must be highest within the command). "
+ "Property bound to another non-scalar parameter: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.IsScalar())
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (otherParameter.Order > parameter.Order)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,43 +0,0 @@
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeRequiredIfPropertyRequiredAnalyzer()
: AnalyzerBase(
"Parameters bound to required properties cannot be marked as non-required",
"This parameter cannot be marked as non-required because it's bound to a required property."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
if (!property.IsRequired())
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.IsRequired != false)
return;
context.ReportDiagnostic(CreateDiagnostic(propertyDeclaration.Identifier.GetLocation()));
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,63 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeSingleIfNonRequiredAnalyzer()
: AnalyzerBase(
"Parameters marked as non-required are limited to one per command",
"This parameter is non-required so it must be the only such parameter in the command. "
+ "Property bound to another non-required parameter: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.IsRequired != false)
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (otherParameter.IsRequired == false)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,63 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustBeSingleIfNonScalarAnalyzer()
: AnalyzerBase(
"Parameters of non-scalar types are limited to one per command",
"This parameter has a non-scalar type so it must be the only such parameter in the command. "
+ "Property bound to another non-scalar parameter: `{0}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.IsScalar())
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (!otherParameter.IsScalar())
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,75 +0,0 @@
using System;
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustHaveUniqueNameAnalyzer()
: AnalyzerBase(
"Parameters must have unique names",
"This parameter's name must be unique within the command (comparison IS NOT case sensitive). "
+ "Specified name: `{0}`. "
+ "Property bound to another parameter with the same name: `{1}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (string.IsNullOrWhiteSpace(parameter.Name))
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (string.IsNullOrWhiteSpace(otherParameter.Name))
continue;
if (
string.Equals(
parameter.Name,
otherParameter.Name,
StringComparison.OrdinalIgnoreCase
)
)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
parameter.Name,
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,62 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustHaveUniqueOrderAnalyzer()
: AnalyzerBase(
"Parameters must have unique order",
"This parameter's order must be unique within the command. "
+ "Specified order: {0}. "
+ "Property bound to another parameter with the same order: `{1}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
if (property.ContainingType is null)
return;
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
var otherProperties = property
.ContainingType.GetMembers()
.OfType<IPropertySymbol>()
.Where(m => !m.Equals(property))
.ToArray();
foreach (var otherProperty in otherProperties)
{
var otherParameter = CommandParameterSymbol.TryResolve(otherProperty);
if (otherParameter is null)
continue;
if (parameter.Order == otherParameter.Order)
{
context.ReportDiagnostic(
CreateDiagnostic(
propertyDeclaration.Identifier.GetLocation(),
parameter.Order,
otherProperty.Name
)
);
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,65 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustHaveValidConverterAnalyzer()
: AnalyzerBase(
$"Parameter converters must derive from `{SymbolNames.CliFxBindingConverterClass}`",
$"Converter specified for this parameter must derive from a compatible `{SymbolNames.CliFxBindingConverterClass}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
if (parameter.ConverterType is null)
return;
var converterValueType = parameter
.ConverterType.GetBaseTypes()
.FirstOrDefault(t =>
t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingConverterClass)
)
?.TypeArguments.FirstOrDefault();
// Value returned by the converter must be assignable to the property type
var isCompatible =
converterValueType is not null
&& (
parameter.IsScalar()
// Scalar
? context.Compilation.IsAssignable(converterValueType, property.Type)
// Non-scalar (assume we can handle all IEnumerable types for simplicity)
: property.Type.TryGetEnumerableUnderlyingType() is { } enumerableUnderlyingType
&& context.Compilation.IsAssignable(
converterValueType,
enumerableUnderlyingType
)
);
if (!isCompatible)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,58 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class ParameterMustHaveValidValidatorsAnalyzer()
: AnalyzerBase(
$"Parameter validators must derive from `{SymbolNames.CliFxBindingValidatorClass}`",
$"Each validator specified for this parameter must derive from a compatible `{SymbolNames.CliFxBindingValidatorClass}`."
)
{
private void Analyze(
SyntaxNodeAnalysisContext context,
PropertyDeclarationSyntax propertyDeclaration,
IPropertySymbol property
)
{
var parameter = CommandParameterSymbol.TryResolve(property);
if (parameter is null)
return;
foreach (var validatorType in parameter.ValidatorTypes)
{
var validatorValueType = validatorType
.GetBaseTypes()
.FirstOrDefault(t =>
t.ConstructedFrom.DisplayNameMatches(SymbolNames.CliFxBindingValidatorClass)
)
?.TypeArguments.FirstOrDefault();
// Value passed to the validator must be assignable from the property type
var isCompatible =
validatorValueType is not null
&& context.Compilation.IsAssignable(property.Type, validatorValueType);
if (!isCompatible)
{
context.ReportDiagnostic(
CreateDiagnostic(propertyDeclaration.Identifier.GetLocation())
);
// No need to report multiple identical diagnostics on the same node
break;
}
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.HandlePropertyDeclaration(Analyze);
}
}

View File

@@ -1,74 +0,0 @@
using System.Linq;
using CliFx.Analyzers.ObjectModel;
using CliFx.Analyzers.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SystemConsoleShouldBeAvoidedAnalyzer()
: AnalyzerBase(
$"Avoid calling `System.Console` where `{SymbolNames.CliFxConsoleInterface}` is available",
$"Use the provided `{SymbolNames.CliFxConsoleInterface}` abstraction instead of `System.Console` to ensure that the command can be tested in isolation.",
DiagnosticSeverity.Warning
)
{
private MemberAccessExpressionSyntax? TryGetSystemConsoleMemberAccess(
SyntaxNodeAnalysisContext context,
SyntaxNode node
)
{
var currentNode = node;
while (currentNode is MemberAccessExpressionSyntax memberAccess)
{
var member = context.SemanticModel.GetSymbolInfo(memberAccess).Symbol;
if (member?.ContainingType?.DisplayNameMatches("System.Console") == true)
{
return memberAccess;
}
// Get inner expression, which may be another member access expression.
// Example: System.Console.Error
// ~~~~~~~~~~~~~~ <- inner member access expression
// -------------------- <- outer member access expression
currentNode = memberAccess.Expression;
}
return null;
}
private void Analyze(SyntaxNodeAnalysisContext context)
{
// Try to get a member access on System.Console in the current expression,
// or in any of its inner expressions.
var systemConsoleMemberAccess = TryGetSystemConsoleMemberAccess(context, context.Node);
if (systemConsoleMemberAccess is null)
return;
// Check if IConsole is available in scope as an alternative to System.Console
var isConsoleInterfaceAvailable = context
.Node.Ancestors()
.OfType<MethodDeclarationSyntax>()
.SelectMany(m => m.ParameterList.Parameters)
.Select(p => p.Type)
.Select(t => context.SemanticModel.GetSymbolInfo(t).Symbol)
.Where(s => s is not null)
.Any(s => s.DisplayNameMatches(SymbolNames.CliFxConsoleInterface));
if (isConsoleInterfaceAvailable)
{
context.ReportDiagnostic(CreateDiagnostic(systemConsoleMemberAccess.GetLocation()));
}
}
public override void Initialize(AnalysisContext context)
{
base.Initialize(context);
context.RegisterSyntaxNodeAction(Analyze, SyntaxKind.SimpleMemberAccessExpression);
}
}

View File

@@ -1,98 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
namespace CliFx.Analyzers.Utils.Extensions;
internal static class RoslynExtensions
{
public static bool DisplayNameMatches(this ISymbol symbol, string name) =>
string.Equals(
// Fully qualified name, without `global::`
symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
name,
StringComparison.Ordinal
);
public static IEnumerable<INamedTypeSymbol> GetBaseTypes(this ITypeSymbol type)
{
var current = type.BaseType;
while (current is not null)
{
yield return current;
current = current.BaseType;
}
}
public static ITypeSymbol? TryGetEnumerableUnderlyingType(this ITypeSymbol type) =>
type
.AllInterfaces.FirstOrDefault(i =>
i.ConstructedFrom.SpecialType
== SpecialType.System_Collections_Generic_IEnumerable_T
)
?.TypeArguments[0];
// Detect if the property is required through roundabout means so as to not have to take dependency
// on higher versions of the C# compiler.
public static bool IsRequired(this IPropertySymbol property) =>
property
// Can't rely on the RequiredMemberAttribute because it's generated by the compiler, not added by the user,
// so we have to check for the presence of the `required` modifier in the syntax tree instead.
.DeclaringSyntaxReferences.Select(r => r.GetSyntax())
.OfType<PropertyDeclarationSyntax>()
.SelectMany(p => p.Modifiers)
.Any(m => m.IsKind((SyntaxKind)8447));
public static bool IsAssignable(
this Compilation compilation,
ITypeSymbol source,
ITypeSymbol destination
) => compilation.ClassifyConversion(source, destination).Exists;
public static void HandleClassDeclaration(
this AnalysisContext analysisContext,
Action<SyntaxNodeAnalysisContext, ClassDeclarationSyntax, ITypeSymbol> analyze
)
{
analysisContext.RegisterSyntaxNodeAction(
ctx =>
{
if (ctx.Node is not ClassDeclarationSyntax classDeclaration)
return;
var type = ctx.SemanticModel.GetDeclaredSymbol(classDeclaration);
if (type is null)
return;
analyze(ctx, classDeclaration, type);
},
SyntaxKind.ClassDeclaration
);
}
public static void HandlePropertyDeclaration(
this AnalysisContext analysisContext,
Action<SyntaxNodeAnalysisContext, PropertyDeclarationSyntax, IPropertySymbol> analyze
)
{
analysisContext.RegisterSyntaxNodeAction(
ctx =>
{
if (ctx.Node is not PropertyDeclarationSyntax propertyDeclaration)
return;
var property = ctx.SemanticModel.GetDeclaredSymbol(propertyDeclaration);
if (property is null)
return;
analyze(ctx, propertyDeclaration, property);
},
SyntaxKind.PropertyDeclaration
);
}
}

View File

@@ -1,18 +0,0 @@
using System;
namespace CliFx.Analyzers.Utils.Extensions;
internal static class StringExtensions
{
public static string TrimEnd(
this string str,
string sub,
StringComparison comparison = StringComparison.Ordinal
)
{
while (str.EndsWith(sub, comparison))
str = str[..^sub.Length];
return str;
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
@@ -7,14 +7,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.14.0" /> <PackageReference Include="BenchmarkDotNet" Version="0.15.2" />
<PackageReference Include="clipr" Version="1.6.1" /> <PackageReference Include="clipr" Version="1.6.1" />
<PackageReference Include="Cocona" Version="2.2.0" /> <PackageReference Include="Cocona" Version="2.2.0" />
<PackageReference Include="CommandLineParser" Version="2.9.1" /> <PackageReference Include="CommandLineParser" Version="2.9.1" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
<PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" /> <PackageReference Include="McMaster.Extensions.CommandLineUtils" Version="4.1.1" />
<PackageReference Include="PowerArgs" Version="4.0.3" /> <PackageReference Include="PowerArgs" Version="4.0.3" />
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.22272.1" /> <PackageReference Include="System.CommandLine" Version="2.0.0-beta6.25358.103" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -1,19 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<ApplicationIcon>../favicon.ico</ApplicationIcon> <ApplicationIcon>../favicon.ico</ApplicationIcon>
<PublishAot>true</PublishAot>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" /> <ProjectReference Include="..\CliFx.SourceGeneration\CliFx.SourceGeneration.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq; using System.Linq;
namespace CliFx.Demo.Domain; namespace CliFx.Demo.Domain;
@@ -24,5 +23,5 @@ public partial record Library(IReadOnlyList<Book> Books)
public partial record Library public partial record Library
{ {
public static Library Empty { get; } = new(Array.Empty<Book>()); public static Library Empty { get; } = new([]);
} }

View File

@@ -0,0 +1,6 @@
using System.Text.Json.Serialization;
namespace CliFx.Demo.Domain;
[JsonSerializable(typeof(Library))]
public partial class LibraryJsonContext : JsonSerializerContext;

View File

@@ -11,7 +11,7 @@ public class LibraryProvider
private void StoreLibrary(Library library) private void StoreLibrary(Library library)
{ {
var data = JsonSerializer.Serialize(library); var data = JsonSerializer.Serialize(library, LibraryJsonContext.Default.Library);
File.WriteAllText(StorageFilePath, data); File.WriteAllText(StorageFilePath, data);
} }
@@ -22,7 +22,8 @@ public class LibraryProvider
var data = File.ReadAllText(StorageFilePath); var data = File.ReadAllText(StorageFilePath);
return JsonSerializer.Deserialize<Library>(data) ?? Library.Empty; return JsonSerializer.Deserialize(data, LibraryJsonContext.Default.Library)
?? Library.Empty;
} }
public Book? TryGetBook(string title) => public Book? TryGetBook(string title) =>

View File

@@ -2,20 +2,17 @@
using CliFx.Demo.Domain; using CliFx.Demo.Domain;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
// We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands
var services = new ServiceCollection();
services.AddSingleton<LibraryProvider>();
// Register all commands as transient services
foreach (var commandType in commandTypes)
services.AddTransient(commandType);
return await new CliApplicationBuilder() return await new CliApplicationBuilder()
.SetDescription("Demo application showcasing CliFx features.") .SetDescription("Demo application showcasing CliFx features.")
.AddCommandsFromThisAssembly() .AddCommandsFromThisAssembly()
.UseTypeActivator(commandTypes => .UseTypeActivator(services.BuildServiceProvider())
{
// We use Microsoft.Extensions.DependencyInjection for injecting dependencies in commands
var services = new ServiceCollection();
services.AddSingleton<LibraryProvider>();
// Register all commands as transient services
foreach (var commandType in commandTypes)
services.AddTransient(commandType);
return services.BuildServiceProvider();
})
.Build() .Build()
.RunAsync(); .RunAsync();

View File

@@ -1,13 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <TargetFramework>netstandard2.0</TargetFramework>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies> <CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
<GenerateDependencyFile>true</GenerateDependencyFile> <GenerateDependencyFile>true</GenerateDependencyFile>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules> <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<NoWarn>$(NoWarn);RS1025;RS1026</NoWarn> <NoWarn>$(NoWarn);RS1035</NoWarn>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<!-- <!--
Because this project only has a single target framework, the condition in Because this project only has a single target framework, the condition in
@@ -15,13 +13,15 @@
--> -->
<Nullable>annotations</Nullable> <Nullable>annotations</Nullable>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
<!-- Make sure to target the lowest possible version of the compiler for wider support --> <!-- Make sure to target the lowest possible version of the compiler for wider support -->
<PackageReference Include="Microsoft.CodeAnalysis" Version="3.0.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.CodeAnalysis" Version="4.11.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="3.0.0" PrivateAssets="all" /> <PackageReference
<PackageReference Include="PolyShim" Version="1.14.0" PrivateAssets="all" /> Include="Microsoft.CodeAnalysis.CSharp"
Version="4.11.0"
PrivateAssets="all"
/>
<PackageReference Include="PolyShim" Version="1.15.0" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
</Project>
</Project>

View File

@@ -0,0 +1,130 @@
using System.Linq;
using CliFx.SourceGeneration.SemanticModel;
using CliFx.SourceGeneration.Utils.Extensions;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace CliFx.SourceGeneration;
[Generator]
public class CommandSchemaGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var values = context.SyntaxProvider.ForAttributeWithMetadataName<(
CommandSymbol?,
Diagnostic?
)>(
KnownSymbolNames.CliFxCommandAttribute,
(n, _) => n is TypeDeclarationSyntax,
(x, _) =>
{
// Predicate above ensures that these casts are safe
var commandTypeSyntax = (TypeDeclarationSyntax)x.TargetNode;
var commandTypeSymbol = (INamedTypeSymbol)x.TargetSymbol;
// Check if the target type and all its containing types are partial
if (
commandTypeSyntax
.AncestorsAndSelf()
.Any(a =>
a is TypeDeclarationSyntax t
&& !t.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword))
)
)
{
return (
null,
Diagnostic.Create(
DiagnosticDescriptors.CommandMustBePartial,
commandTypeSyntax.Identifier.GetLocation()
)
);
}
// Check if the target type implements ICommand
var hasCommandInterface = commandTypeSymbol.AllInterfaces.Any(i =>
i.DisplayNameMatches(KnownSymbolNames.CliFxCommandInterface)
);
if (!hasCommandInterface)
{
return (
null,
Diagnostic.Create(
DiagnosticDescriptors.CommandMustImplementInterface,
commandTypeSymbol.Locations.First()
)
);
}
// Resolve the command
var commandAttribute = x.Attributes.First(a =>
a.AttributeClass?.DisplayNameMatches(KnownSymbolNames.CliFxCommandAttribute)
== true
);
var command = CommandSymbol.FromSymbol(commandTypeSymbol, commandAttribute);
// TODO: validate command
return (command, null);
}
);
// Report diagnostics
var diagnostics = values.Select((v, _) => v.Item2).WhereNotNull();
context.RegisterSourceOutput(diagnostics, (x, d) => x.ReportDiagnostic(d));
// Generate command schemas
var symbols = values.Select((v, _) => v.Item1).WhereNotNull();
context.RegisterSourceOutput(
symbols,
(x, c) =>
x.AddSource(
$"{c.Type.FullyQualifiedName}.CommandSchema.Generated.cs",
// lang=csharp
$$"""
namespace {{c.Type.Namespace}};
partial class {{c.Type.Name}}
{
public static CliFx.Schema.CommandSchema<{{c.Type.FullyQualifiedName}}> Schema { get; } = {{c.GenerateSchemaInitializationCode()}};
}
"""
)
);
// Generate extension methods
var symbolsCollected = symbols.Collect();
context.RegisterSourceOutput(
symbolsCollected,
(x, cs) =>
x.AddSource(
"CommandSchemaExtensions.Generated.cs",
// lang=csharp
$$"""
namespace CliFx;
static partial class GeneratedExtensions
{
public static CliFx.CliApplicationBuilder AddCommandsFromThisAssembly(this CliFx.CliApplicationBuilder builder)
{
{{
cs.Select(c => c.Type.FullyQualifiedName)
.Select(t =>
// lang=csharp
$"builder.AddCommand({t}.Schema);"
)
.JoinToString("\n")
}}
return builder;
}
}
"""
)
);
}
}

View File

@@ -0,0 +1,27 @@
using CliFx.SourceGeneration.SemanticModel;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration;
internal static class DiagnosticDescriptors
{
public static DiagnosticDescriptor CommandMustBePartial { get; } =
new(
$"{nameof(CliFx)}_{nameof(CommandMustBePartial)}",
"Command types must be declared as `partial`",
"This type (and all its containing types, if present) must be declared as `partial` in order to be a valid command.",
"CliFx",
DiagnosticSeverity.Error,
true
);
public static DiagnosticDescriptor CommandMustImplementInterface { get; } =
new(
$"{nameof(CliFx)}_{nameof(CommandMustImplementInterface)}",
$"Commands must implement the `{KnownSymbolNames.CliFxCommandInterface}` interface",
$"This type must implement the `{KnownSymbolNames.CliFxCommandInterface}` interface in order to be a valid command.",
"CliFx",
DiagnosticSeverity.Error,
true
);
}

View File

@@ -0,0 +1,66 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal abstract partial class CommandInputSymbol(
PropertyDescriptor property,
bool isSequence,
string? description,
TypeDescriptor? converterType,
IReadOnlyList<TypeDescriptor> validatorTypes
)
{
public PropertyDescriptor Property { get; } = property;
public bool IsSequence { get; } = isSequence;
public string? Description { get; } = description;
public TypeDescriptor? ConverterType { get; } = converterType;
public IReadOnlyList<TypeDescriptor> ValidatorTypes { get; } = validatorTypes;
}
internal partial class CommandInputSymbol : IEquatable<CommandInputSymbol>
{
public bool Equals(CommandInputSymbol? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Property.Equals(other.Property)
&& IsSequence == other.IsSequence
&& Description == other.Description
&& Equals(ConverterType, other.ConverterType)
&& ValidatorTypes.SequenceEqual(other.ValidatorTypes);
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((CommandInputSymbol)obj);
}
public override int GetHashCode() =>
HashCode.Combine(Property, IsSequence, Description, ConverterType, ValidatorTypes);
}
internal partial class CommandInputSymbol
{
public static bool IsSequenceType(ITypeSymbol type) =>
type.AllInterfaces.Any(i =>
i.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T
)
&& type.SpecialType != SpecialType.System_String;
}

View File

@@ -0,0 +1,90 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.SourceGeneration.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal partial class CommandOptionSymbol(
PropertyDescriptor property,
bool isSequence,
string? name,
char? shortName,
string? environmentVariable,
bool isRequired,
string? description,
TypeDescriptor? converterType,
IReadOnlyList<TypeDescriptor> validatorTypes
) : CommandInputSymbol(property, isSequence, description, converterType, validatorTypes)
{
public string? Name { get; } = name;
public char? ShortName { get; } = shortName;
public string? EnvironmentVariable { get; } = environmentVariable;
public bool IsRequired { get; } = isRequired;
}
internal partial class CommandOptionSymbol : IEquatable<CommandOptionSymbol>
{
public bool Equals(CommandOptionSymbol? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return base.Equals(other)
&& Name == other.Name
&& ShortName == other.ShortName
&& EnvironmentVariable == other.EnvironmentVariable
&& IsRequired == other.IsRequired;
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((CommandOptionSymbol)obj);
}
public override int GetHashCode() =>
HashCode.Combine(base.GetHashCode(), Name, ShortName, EnvironmentVariable, IsRequired);
}
internal partial class CommandOptionSymbol
{
public static CommandOptionSymbol FromSymbol(
IPropertySymbol property,
AttributeData attribute
) =>
new(
PropertyDescriptor.FromSymbol(property),
IsSequenceType(property.Type),
attribute
.ConstructorArguments.FirstOrDefault(a =>
a.Type?.SpecialType == SpecialType.System_String
)
.Value as string,
attribute
.ConstructorArguments.FirstOrDefault(a =>
a.Type?.SpecialType == SpecialType.System_Char
)
.Value as char?,
attribute.GetNamedArgumentValue("EnvironmentVariable", default(string)),
attribute.GetNamedArgumentValue("IsRequired", property.IsRequired),
attribute.GetNamedArgumentValue("Description", default(string)),
TypeDescriptor.FromSymbol(attribute.GetNamedArgumentValue<ITypeSymbol?>("Converter")),
attribute
.GetNamedArgumentValues<ITypeSymbol>("Validators")
.Select(TypeDescriptor.FromSymbol)
.ToArray()
);
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.SourceGeneration.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal partial class CommandParameterSymbol(
PropertyDescriptor property,
bool isSequence,
int order,
string name,
bool isRequired,
string? description,
TypeDescriptor? converterType,
IReadOnlyList<TypeDescriptor> validatorTypes
) : CommandInputSymbol(property, isSequence, description, converterType, validatorTypes)
{
public int Order { get; } = order;
public string Name { get; } = name;
public bool IsRequired { get; } = isRequired;
}
internal partial class CommandParameterSymbol : IEquatable<CommandParameterSymbol>
{
public bool Equals(CommandParameterSymbol? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return base.Equals(other)
&& Order == other.Order
&& Name == other.Name
&& IsRequired == other.IsRequired;
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((CommandParameterSymbol)obj);
}
public override int GetHashCode() =>
HashCode.Combine(base.GetHashCode(), Order, Name, IsRequired);
}
internal partial class CommandParameterSymbol
{
public static CommandParameterSymbol FromSymbol(
IPropertySymbol property,
AttributeData attribute
) =>
new(
PropertyDescriptor.FromSymbol(property),
IsSequenceType(property.Type),
(int)attribute.ConstructorArguments.First().Value!,
attribute.GetNamedArgumentValue("Name", default(string)),
attribute.GetNamedArgumentValue("IsRequired", true),
attribute.GetNamedArgumentValue("Description", default(string)),
TypeDescriptor.FromSymbol(attribute.GetNamedArgumentValue<ITypeSymbol>("Converter")),
attribute
.GetNamedArgumentValues<ITypeSymbol>("Validators")
.Select(TypeDescriptor.FromSymbol)
.ToArray()
);
}

View File

@@ -0,0 +1,165 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CliFx.SourceGeneration.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal partial class CommandSymbol(
TypeDescriptor type,
string? name,
string? description,
IReadOnlyList<CommandInputSymbol> inputs
)
{
public TypeDescriptor Type { get; } = type;
public string? Name { get; } = name;
public string? Description { get; } = description;
public IReadOnlyList<CommandInputSymbol> Inputs { get; } = inputs;
public IReadOnlyList<CommandParameterSymbol> Parameters =>
Inputs.OfType<CommandParameterSymbol>().ToArray();
public IReadOnlyList<CommandOptionSymbol> Options =>
Inputs.OfType<CommandOptionSymbol>().ToArray();
private string GeneratePropertyBindingInitializationCode(PropertyDescriptor property) =>
// lang=csharp
$$"""
new CliFx.Schema.PropertyBinding<{{Type.FullyQualifiedName}}, {{property
.Type
.FullyQualifiedName}}>(
(obj) => obj.{{property.Name}},
(obj, value) => obj.{{property.Name}} = value
)
""";
private string GenerateSchemaInitializationCode(CommandInputSymbol input) =>
input switch
{
CommandParameterSymbol parameter =>
// lang=csharp
$$"""
new CliFx.Schema.CommandParameterSchema<{{Type.FullyQualifiedName}}, {{parameter
.Property
.Type
.FullyQualifiedName}}>(
{{GeneratePropertyBindingInitializationCode(parameter.Property)}},
{{parameter.IsSequence}},
{{parameter.Order}},
"{{parameter.Name}}",
{{parameter.IsRequired}},
"{{parameter.Description}}",
// TODO,
// TODO
);
""",
CommandOptionSymbol option =>
// lang=csharp
$$"""
new CliFx.Schema.CommandOptionSchema<{{Type.FullyQualifiedName}}, {{option
.Property
.Type
.FullyQualifiedName}}>(
{{GeneratePropertyBindingInitializationCode(option.Property)}},
{{option.IsSequence}},
"{{option.Name}}",
'{{option.ShortName}}',
"{{option.EnvironmentVariable}}",
{{option.IsRequired}},
"{{option.Description}}",
// TODO,
// TODO
);
""",
_ => throw new ArgumentOutOfRangeException(nameof(input), input, null),
};
public string GenerateSchemaInitializationCode() =>
// lang=csharp
$$"""
new CliFx.Schema.CommandSchema<{{Type.FullyQualifiedName}}>(
"{{Name}}",
"{{Description}}",
new CliFx.Schema.CommandInputSchema[]
{
{{Inputs.Select(GenerateSchemaInitializationCode).JoinToString(",\n")}}
}
)
""";
}
internal partial class CommandSymbol : IEquatable<CommandSymbol>
{
public bool Equals(CommandSymbol? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Type.Equals(other.Type)
&& Name == other.Name
&& Description == other.Description
&& Inputs.SequenceEqual(other.Inputs);
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((CommandSymbol)obj);
}
public override int GetHashCode() => HashCode.Combine(Type, Name, Description, Inputs);
}
internal partial class CommandSymbol
{
public static CommandSymbol FromSymbol(INamedTypeSymbol symbol, AttributeData attribute)
{
var inputs = new List<CommandInputSymbol>();
foreach (var property in symbol.GetMembers().OfType<IPropertySymbol>())
{
var parameterAttribute = property
.GetAttributes()
.FirstOrDefault(a =>
a.AttributeClass?.Name == KnownSymbolNames.CliFxCommandParameterAttribute
);
if (parameterAttribute is not null)
{
inputs.Add(CommandParameterSymbol.FromSymbol(property, parameterAttribute));
continue;
}
var optionAttribute = property
.GetAttributes()
.FirstOrDefault(a =>
a.AttributeClass?.Name == KnownSymbolNames.CliFxCommandOptionAttribute
);
if (optionAttribute is not null)
{
inputs.Add(CommandOptionSymbol.FromSymbol(property, optionAttribute));
continue;
}
}
return new CommandSymbol(
TypeDescriptor.FromSymbol(symbol),
attribute.ConstructorArguments.FirstOrDefault().Value as string,
attribute.GetNamedArgumentValue("Description", default(string)),
inputs
);
}
}

View File

@@ -1,13 +1,10 @@
namespace CliFx.Analyzers.ObjectModel; namespace CliFx.SourceGeneration.SemanticModel;
internal static class SymbolNames internal static class KnownSymbolNames
{ {
public const string CliFxCommandInterface = "CliFx.ICommand"; public const string CliFxCommandInterface = "CliFx.ICommand";
public const string CliFxCommandAttribute = "CliFx.Attributes.CommandAttribute"; public const string CliFxCommandAttribute = "CliFx.Attributes.CommandAttribute";
public const string CliFxCommandParameterAttribute = public const string CliFxCommandParameterAttribute =
"CliFx.Attributes.CommandParameterAttribute"; "CliFx.Attributes.CommandParameterAttribute";
public const string CliFxCommandOptionAttribute = "CliFx.Attributes.CommandOptionAttribute"; public const string CliFxCommandOptionAttribute = "CliFx.Attributes.CommandOptionAttribute";
public const string CliFxConsoleInterface = "CliFx.Infrastructure.IConsole";
public const string CliFxBindingConverterClass = "CliFx.Extensibility.BindingConverter<T>";
public const string CliFxBindingValidatorClass = "CliFx.Extensibility.BindingValidator<T>";
} }

View File

@@ -0,0 +1,44 @@
using System;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal partial class PropertyDescriptor(TypeDescriptor type, string name)
{
public TypeDescriptor Type { get; } = type;
public string Name { get; } = name;
}
internal partial class PropertyDescriptor : IEquatable<PropertyDescriptor>
{
public bool Equals(PropertyDescriptor? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return Type.Equals(other.Type) && Name == other.Name;
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((PropertyDescriptor)obj);
}
public override int GetHashCode() => HashCode.Combine(Type, Name);
}
internal partial class PropertyDescriptor
{
public static PropertyDescriptor FromSymbol(IPropertySymbol symbol) =>
new(TypeDescriptor.FromSymbol(symbol.Type), symbol.Name);
}

View File

@@ -0,0 +1,47 @@
using System;
using CliFx.SourceGeneration.Utils.Extensions;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.SemanticModel;
internal partial class TypeDescriptor(string fullyQualifiedName)
{
public string FullyQualifiedName { get; } = fullyQualifiedName;
public string Namespace { get; } = fullyQualifiedName.SubstringUntilLast(".");
public string Name { get; } = fullyQualifiedName.SubstringAfterLast(".");
}
internal partial class TypeDescriptor : IEquatable<TypeDescriptor>
{
public bool Equals(TypeDescriptor? other)
{
if (ReferenceEquals(null, other))
return false;
if (ReferenceEquals(this, other))
return true;
return FullyQualifiedName == other.FullyQualifiedName;
}
public override bool Equals(object? obj)
{
if (ReferenceEquals(null, obj))
return false;
if (ReferenceEquals(this, obj))
return true;
if (obj.GetType() != GetType())
return false;
return Equals((TypeDescriptor)obj);
}
public override int GetHashCode() => FullyQualifiedName.GetHashCode();
}
internal partial class TypeDescriptor
{
public static TypeDescriptor FromSymbol(ITypeSymbol symbol) =>
new(symbol.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat));
}

View File

@@ -0,0 +1,16 @@
using System.Collections.Generic;
namespace CliFx.SourceGeneration.Utils.Extensions;
internal static class CollectionExtensions
{
public static IEnumerable<T> WhereNotNull<T>(this IEnumerable<T?> source)
where T : class
{
foreach (var i in source)
{
if (i is not null)
yield return i;
}
}
}

View File

@@ -0,0 +1,9 @@
using System;
namespace CliFx.SourceGeneration.Utils.Extensions;
internal static class GenericExtensions
{
public static TOut Pipe<TIn, TOut>(this TIn input, Func<TIn, TOut> transform) =>
transform(input);
}

View File

@@ -0,0 +1,39 @@
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace CliFx.SourceGeneration.Utils.Extensions;
internal static class RoslynExtensions
{
public static bool DisplayNameMatches(this ISymbol symbol, string name) =>
string.Equals(
// Fully qualified name, without `global::`
symbol.ToDisplayString(SymbolDisplayFormat.CSharpErrorMessageFormat),
name,
StringComparison.Ordinal
);
public static T GetNamedArgumentValue<T>(
this AttributeData attribute,
string name,
T defaultValue = default
) =>
attribute.NamedArguments.FirstOrDefault(i => i.Key == name).Value.Value is T valueAsT
? valueAsT
: defaultValue;
public static IReadOnlyList<T> GetNamedArgumentValues<T>(
this AttributeData attribute,
string name
)
where T : class =>
attribute.NamedArguments.FirstOrDefault(i => i.Key == name).Value.Values.CastArray<T>();
public static IncrementalValuesProvider<T> WhereNotNull<T>(
this IncrementalValuesProvider<T?> values
)
where T : class => values.Where(i => i is not null);
}

View File

@@ -0,0 +1,30 @@
using System;
using System.Collections.Generic;
namespace CliFx.SourceGeneration.Utils.Extensions;
internal static class StringExtensions
{
public static string SubstringUntilLast(
this string str,
string sub,
StringComparison comparison = StringComparison.Ordinal
)
{
var index = str.LastIndexOf(sub, comparison);
return index < 0 ? str : str[..index];
}
public static string SubstringAfterLast(
this string str,
string sub,
StringComparison comparison = StringComparison.Ordinal
)
{
var index = str.LastIndexOf(sub, comparison);
return index >= 0 ? str.Substring(index + sub.Length, str.Length - index - sub.Length) : "";
}
public static string JoinToString<T>(this IEnumerable<T> source, string separator) =>
string.Join(separator, source);
}

View File

@@ -7,12 +7,12 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\CliFx\CliFx.csproj" /> <ProjectReference Include="..\CliFx\CliFx.csproj" />
<ProjectReference Include="..\CliFx.Analyzers\CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" /> <ProjectReference Include="..\CliFx.SourceGeneration\CliFx.SourceGeneration.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Reflection; using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace CliFx.Tests.Dummy; namespace CliFx.Tests.Dummy;
@@ -13,7 +12,7 @@ public static class Program
public static string FilePath { get; } = public static string FilePath { get; } =
Path.ChangeExtension( Path.ChangeExtension(
Assembly.GetExecutingAssembly().Location, Assembly.GetExecutingAssembly().Location,
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "exe" : null OperatingSystem.IsWindows() ? "exe" : null
); );
public static async Task Main() public static async Task Main()

View File

@@ -19,7 +19,7 @@ public class ApplicationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutp
.UseConsole(FakeConsole) .UseConsole(FakeConsole)
.Build(); .Build();
var exitCode = await app.RunAsync(Array.Empty<string>(), new Dictionary<string, string>()); var exitCode = await app.RunAsync([], new Dictionary<string, string>());
// Assert // Assert
exitCode.Should().Be(0); exitCode.Should().Be(0);
@@ -45,7 +45,7 @@ public class ApplicationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutp
.UseTypeActivator(Activator.CreateInstance!) .UseTypeActivator(Activator.CreateInstance!)
.Build(); .Build();
var exitCode = await app.RunAsync(Array.Empty<string>(), new Dictionary<string, string>()); var exitCode = await app.RunAsync([], new Dictionary<string, string>());
// Assert // Assert
exitCode.Should().Be(0); exitCode.Should().Be(0);
@@ -60,7 +60,7 @@ public class ApplicationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutp
.UseConsole(FakeConsole) .UseConsole(FakeConsole)
.Build(); .Build();
var exitCode = await app.RunAsync(Array.Empty<string>(), new Dictionary<string, string>()); var exitCode = await app.RunAsync([], new Dictionary<string, string>());
// Assert // Assert
exitCode.Should().NotBe(0); exitCode.Should().NotBe(0);

View File

@@ -94,7 +94,7 @@ public class CancellationSpecs(ITestOutputHelper testOutput) : SpecsBase(testOut
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
@@ -9,18 +9,18 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Basic.Reference.Assemblies.Net80" Version="1.7.9" /> <PackageReference Include="Basic.Reference.Assemblies.Net80" Version="1.8.3" />
<PackageReference Include="CliWrap" Version="3.7.0" /> <PackageReference Include="CliWrap" Version="3.9.0" />
<PackageReference Include="coverlet.collector" Version="6.0.3" PrivateAssets="all" /> <PackageReference Include="coverlet.collector" Version="6.0.4" PrivateAssets="all" />
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" Version="7.0.0" /> <PackageReference Include="FluentAssertions" Version="8.6.0" />
<PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" /> <PackageReference Include="GitHubActionsTestLogger" Version="2.4.1" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.11.0" /> <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="PolyShim" Version="1.14.0" PrivateAssets="all" /> <PackageReference Include="PolyShim" Version="1.15.0" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.9.2" /> <PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.0" PrivateAssets="all" /> <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" PrivateAssets="all" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@@ -88,7 +88,7 @@ public class ConsoleSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -144,7 +144,7 @@ public class ConsoleSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
FakeConsole.WriteInput("Hello world"); FakeConsole.WriteInput("Hello world");
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -191,7 +191,7 @@ public class ConsoleSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
FakeConsole.EnqueueKey(new ConsoleKeyInfo('\0', ConsoleKey.Backspace, false, false, false)); FakeConsole.EnqueueKey(new ConsoleKeyInfo('\0', ConsoleKey.Backspace, false, false, false));
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Utils; using CliFx.Tests.Utils;
@@ -90,7 +89,7 @@ public class EnvironmentSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutp
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string> { ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" } new Dictionary<string, string> { ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" }
); );
@@ -130,7 +129,7 @@ public class EnvironmentSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutp
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string> { ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" } new Dictionary<string, string> { ["ENV_FOO"] = $"bar{Path.PathSeparator}baz" }
); );

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Utils; using CliFx.Tests.Utils;
using CliFx.Tests.Utils.Extensions; using CliFx.Tests.Utils.Extensions;
@@ -34,7 +33,7 @@ public class ErrorReportingSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -73,7 +72,7 @@ public class ErrorReportingSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -119,7 +118,7 @@ public class ErrorReportingSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -156,7 +155,7 @@ public class ErrorReportingSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -194,7 +193,7 @@ public class ErrorReportingSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Utils; using CliFx.Tests.Utils;
using CliFx.Tests.Utils.Extensions; using CliFx.Tests.Utils.Extensions;
@@ -22,7 +21,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -34,7 +33,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_by_running_the_application_with_the_help_option() public async Task I_can_request_the_help_text_by_running_the_application_with_the_implicit_help_option()
{ {
// Arrange // Arrange
var commandType = DynamicCommandBuilder.Compile( var commandType = DynamicCommandBuilder.Compile(
@@ -65,7 +64,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_by_running_the_application_with_the_help_option_even_if_the_default_command_is_not_defined() public async Task I_can_request_the_help_text_by_running_the_application_with_the_implicit_help_option_even_if_the_default_command_is_not_defined()
{ {
// Arrange // Arrange
var commandTypes = DynamicCommandBuilder.CompileMany( var commandTypes = DynamicCommandBuilder.CompileMany(
@@ -102,7 +101,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_for_a_specific_command_by_running_the_application_and_specifying_its_name_with_the_help_option() public async Task I_can_request_the_help_text_for_a_specific_command_by_running_the_application_and_specifying_its_name_with_the_implicit_help_option()
{ {
// Arrange // Arrange
var commandTypes = DynamicCommandBuilder.CompileMany( var commandTypes = DynamicCommandBuilder.CompileMany(
@@ -147,7 +146,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_for_a_specific_nested_command_by_running_the_application_and_specifying_its_name_with_the_help_option() public async Task I_can_request_the_help_text_for_a_specific_nested_command_by_running_the_application_and_specifying_its_name_with_the_implicit_help_option()
{ {
// Arrange // Arrange
var commandTypes = DynamicCommandBuilder.CompileMany( var commandTypes = DynamicCommandBuilder.CompileMany(
@@ -476,7 +475,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_to_see_the_help_and_version_options() public async Task I_can_request_the_help_text_to_see_the_help_and_implicit_version_options()
{ {
// Arrange // Arrange
var commandType = DynamicCommandBuilder.Compile( var commandType = DynamicCommandBuilder.Compile(
@@ -515,7 +514,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_help_text_on_a_named_command_to_see_the_help_option() public async Task I_can_request_the_help_text_on_a_named_command_to_see_the_implicit_help_option()
{ {
// Arrange // Arrange
var commandType = DynamicCommandBuilder.Compile( var commandType = DynamicCommandBuilder.Compile(
@@ -974,7 +973,7 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
} }
[Fact] [Fact]
public async Task I_can_request_the_version_text_by_running_the_application_with_the_version_option() public async Task I_can_request_the_version_text_by_running_the_application_with_the_implicit_version_option()
{ {
// Arrange // Arrange
var application = new CliApplicationBuilder() var application = new CliApplicationBuilder()
@@ -992,4 +991,72 @@ public class HelpTextSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
var stdOut = FakeConsole.ReadOutputString(); var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("v6.9"); stdOut.Trim().Should().Be("v6.9");
} }
[Fact]
public async Task I_cannot_request_the_help_text_by_running_the_application_with_the_implicit_help_option_if_there_is_an_option_with_the_same_identifier()
{
// Arrange
var commandType = DynamicCommandBuilder.Compile(
// lang=csharp
"""
[Command]
public class DefaultCommand : ICommand
{
[CommandOption("help", 'h')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
);
var application = new CliApplicationBuilder()
.AddCommand(commandType)
.UseConsole(FakeConsole)
.SetDescription("This will be in help text")
.Build();
// Act
var exitCode = await application.RunAsync(["--help"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Should().NotContain("This will be in help text");
}
[Fact]
public async Task I_cannot_request_the_version_text_by_running_the_application_with_the_implicit_version_option_if_there_is_an_option_with_the_same_identifier()
{
// Arrange
var commandType = DynamicCommandBuilder.Compile(
// lang=csharp
"""
[Command]
public class DefaultCommand : ICommand
{
[CommandOption("version")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console) => default;
}
"""
);
var application = new CliApplicationBuilder()
.AddCommand(commandType)
.SetVersion("v6.9")
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(["--version"], new Dictionary<string, string>());
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().NotBe("v6.9");
}
} }

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Utils; using CliFx.Tests.Utils;
using CliFx.Tests.Utils.Extensions; using CliFx.Tests.Utils.Extensions;
@@ -587,6 +586,86 @@ public class OptionBindingSpecs(ITestOutputHelper testOutput) : SpecsBase(testOu
stdOut.Trim().Should().Be("-13"); stdOut.Trim().Should().Be("-13");
} }
[Fact]
public async Task I_can_bind_an_option_to_a_property_with_the_same_identifier_as_the_implicit_help_option_and_get_the_correct_value()
{
// Arrange
var commandType = DynamicCommandBuilder.Compile(
// lang=csharp
"""
[Command]
public class Command : ICommand
{
[CommandOption("help", 'h')]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
console.WriteLine(Foo);
return default;
}
}
"""
);
var application = new CliApplicationBuilder()
.AddCommand(commandType)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["--help", "me"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("me");
}
[Fact]
public async Task I_can_bind_an_option_to_a_property_with_the_same_identifier_as_the_implicit_version_option_and_get_the_correct_value()
{
// Arrange
var commandType = DynamicCommandBuilder.Compile(
// lang=csharp
"""
[Command]
public class Command : ICommand
{
[CommandOption("version")]
public string? Foo { get; init; }
public ValueTask ExecuteAsync(IConsole console)
{
console.WriteLine(Foo);
return default;
}
}
"""
);
var application = new CliApplicationBuilder()
.AddCommand(commandType)
.UseConsole(FakeConsole)
.Build();
// Act
var exitCode = await application.RunAsync(
["--version", "1.2.0"],
new Dictionary<string, string>()
);
// Assert
exitCode.Should().Be(0);
var stdOut = FakeConsole.ReadOutputString();
stdOut.Trim().Should().Be("1.2.0");
}
[Fact] [Fact]
public async Task I_can_try_to_bind_a_required_option_to_a_property_and_get_an_error_if_the_user_does_not_provide_the_corresponding_argument() public async Task I_can_try_to_bind_a_required_option_to_a_property_and_get_an_error_if_the_user_does_not_provide_the_corresponding_argument()
{ {
@@ -612,7 +691,7 @@ public class OptionBindingSpecs(ITestOutputHelper testOutput) : SpecsBase(testOu
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -1,5 +1,4 @@
using System; using System.Collections.Generic;
using System.Collections.Generic;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Tests.Utils; using CliFx.Tests.Utils;
using FluentAssertions; using FluentAssertions;
@@ -56,7 +55,7 @@ public class RoutingSpecs(ITestOutputHelper testOutput) : SpecsBase(testOutput)
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -39,7 +39,7 @@ public class TypeActivationSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -75,7 +75,7 @@ public class TypeActivationSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -117,7 +117,7 @@ public class TypeActivationSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -172,7 +172,7 @@ public class TypeActivationSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );
@@ -210,7 +210,7 @@ public class TypeActivationSpecs(ITestOutputHelper testOutput) : SpecsBase(testO
// Act // Act
var exitCode = await application.RunAsync( var exitCode = await application.RunAsync(
Array.Empty<string>(), [],
new Dictionary<string, string>() new Dictionary<string, string>()
); );

View File

@@ -1,7 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using FluentAssertions; using FluentAssertions;
using FluentAssertions.Execution;
using FluentAssertions.Primitives; using FluentAssertions.Primitives;
namespace CliFx.Tests.Utils.Extensions; namespace CliFx.Tests.Utils.Extensions;
@@ -13,7 +12,7 @@ internal static class AssertionExtensions
params IEnumerable<string> lines params IEnumerable<string> lines
) => ) =>
assertions assertions
.Subject.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries) .Subject.Split(['\n', '\r'], StringSplitOptions.RemoveEmptyEntries)
.Should() .Should()
.Equal(lines); .Equal(lines);
@@ -30,7 +29,7 @@ internal static class AssertionExtensions
if (index < 0) if (index < 0)
{ {
Execute.Assertion.FailWith( assertions.CurrentAssertionChain.FailWith(
$"Expected string '{assertions.Subject}' to contain '{value}' after position {lastIndex}." $"Expected string '{assertions.Subject}' to contain '{value}' after position {lastIndex}."
); );
} }

View File

@@ -7,7 +7,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx", "CliFx\CliFx.csproj
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests", "CliFx.Tests\CliFx.Tests.csproj", "{268CF863-65A5-49BB-93CF-08972B7756DC}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Misc", "Misc", "{3AAE8166-BB8E-49DA-844C-3A0EE6BD40A0}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
Directory.Build.props = Directory.Build.props Directory.Build.props = Directory.Build.props
License.txt = License.txt License.txt = License.txt
@@ -20,9 +20,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Demo", "CliFx.Demo\Cl
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{F717347D-8656-44DA-A4A2-BE515E8C4655}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Tests.Dummy", "CliFx.Tests.Dummy\CliFx.Tests.Dummy.csproj", "{F717347D-8656-44DA-A4A2-BE515E8C4655}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.Analyzers", "CliFx.Analyzers\CliFx.Analyzers.csproj", "{F8460D69-F8CF-405C-A6ED-BED02A21DB42}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CliFx.SourceGeneration", "CliFx.SourceGeneration\CliFx.SourceGeneration.csproj", "{F8460D69-F8CF-405C-A6ED-BED02A21DB42}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CliFx.Analyzers.Tests", "CliFx.Analyzers.Tests\CliFx.Analyzers.Tests.csproj", "{49878E75-2097-4C79-9151-B98A28FBB973}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -106,18 +104,6 @@ Global
{F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x64.Build.0 = Release|Any CPU {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x64.Build.0 = Release|Any CPU
{F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.ActiveCfg = Release|Any CPU {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.ActiveCfg = Release|Any CPU
{F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.Build.0 = Release|Any CPU {F8460D69-F8CF-405C-A6ED-BED02A21DB42}.Release|x86.Build.0 = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|Any CPU.Build.0 = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x64.ActiveCfg = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x64.Build.0 = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x86.ActiveCfg = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Debug|x86.Build.0 = Debug|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|Any CPU.ActiveCfg = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|Any CPU.Build.0 = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|x64.ActiveCfg = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|x64.Build.0 = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|x86.ActiveCfg = Release|Any CPU
{49878E75-2097-4C79-9151-B98A28FBB973}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@@ -1,5 +1,4 @@
using System; using CliFx.Schema;
using System.Collections.Generic;
namespace CliFx; namespace CliFx;
@@ -7,15 +6,15 @@ namespace CliFx;
/// Configuration of an application. /// Configuration of an application.
/// </summary> /// </summary>
public class ApplicationConfiguration( public class ApplicationConfiguration(
IReadOnlyList<Type> commandTypes, ApplicationSchema schema,
bool isDebugModeAllowed, bool isDebugModeAllowed,
bool isPreviewModeAllowed bool isPreviewModeAllowed
) )
{ {
/// <summary> /// <summary>
/// Command types defined in the application. /// Application schema.
/// </summary> /// </summary>
public IReadOnlyList<Type> CommandTypes { get; } = commandTypes; public ApplicationSchema Schema { get; } = schema;
/// <summary> /// <summary>
/// Whether debug mode is allowed in the application. /// Whether debug mode is allowed in the application.

View File

@@ -4,29 +4,22 @@ namespace CliFx.Attributes;
/// <summary> /// <summary>
/// Annotates a type that defines a command. /// Annotates a type that defines a command.
/// If the command is named, then the user must provide its name through the
/// command-line arguments in order to execute it.
/// If the command is not named, then it is treated as the application's
/// default command and is executed whenever the user does not provide a command name.
/// </summary> /// </summary>
/// <remarks>
/// Only one default command is allowed per application.
/// All commands registered in an application must have unique names (comparison IS NOT case-sensitive).
/// </remarks>
[AttributeUsage(AttributeTargets.Class, Inherited = false)] [AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class CommandAttribute : Attribute public class CommandAttribute(string? name = null) : Attribute
{ {
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute" />.
/// </summary>
public CommandAttribute(string name) => Name = name;
/// <summary>
/// Initializes an instance of <see cref="CommandAttribute" />.
/// </summary>
public CommandAttribute() { }
/// <summary> /// <summary>
/// Command name. /// Command name.
/// </summary> /// </summary>
/// <remarks> public string? Name { get; } = name;
/// Command can have no name, in which case it's treated as the application's default command.
/// Only one default command is allowed in an application.
/// All commands registered in an application must have unique names (comparison IS NOT case-sensitive).
/// </remarks>
public string? Name { get; }
/// <summary> /// <summary>
/// Command description. /// Command description.

View File

@@ -0,0 +1,19 @@
namespace CliFx.Attributes;
/// <summary>
/// Binds a property to the help option of a command.
/// </summary>
/// <remarks>
/// This attribute is applied automatically by the framework and should not need to be used explicitly.
/// </remarks>
public class CommandHelpOptionAttribute : CommandOptionAttribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandHelpOptionAttribute" />.
/// </summary>
public CommandHelpOptionAttribute()
: base("help", 'h')
{
Description = "Show help for this command.";
}
}

View File

@@ -0,0 +1,34 @@
using System;
using CliFx.Extensibility;
namespace CliFx.Attributes;
/// <summary>
/// Binds a property to a command-line input.
/// </summary>
[AttributeUsage(AttributeTargets.Property)]
public abstract class CommandInputAttribute : Attribute
{
/// <summary>
/// Input description, as shown in the help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Custom converter used for mapping the raw command-line argument into
/// the type and shape expected by the underlying property.
/// </summary>
/// <remarks>
/// Converter must derive from <see cref="BindingConverter{T}" />.
/// </remarks>
public Type? Converter { get; set; }
/// <summary>
/// Custom validators used for verifying the value of the underlying
/// property, after it has been set.
/// </summary>
/// <remarks>
/// Validators must derive from <see cref="BindingValidator{T}" />.
/// </remarks>
public Type[] Validators { get; set; } = [];
}

View File

@@ -1,13 +1,16 @@
using System; using System;
using CliFx.Extensibility;
namespace CliFx.Attributes; namespace CliFx.Attributes;
/// <summary> /// <summary>
/// Annotates a property that defines a command option. /// Binds a property to a command option — a command-line input that is identified by a name and/or a short name.
/// </summary> /// </summary>
/// <remarks>
/// All options in a command must have unique names (comparison IS NOT case-sensitive)
/// and short names (comparison IS case-sensitive).
/// </remarks>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public sealed class CommandOptionAttribute : Attribute public class CommandOptionAttribute : CommandInputAttribute
{ {
/// <summary> /// <summary>
/// Initializes an instance of <see cref="CommandOptionAttribute" />. /// Initializes an instance of <see cref="CommandOptionAttribute" />.
@@ -39,25 +42,16 @@ public sealed class CommandOptionAttribute : Attribute
/// <summary> /// <summary>
/// Option name. /// Option name.
/// </summary> /// </summary>
/// <remarks>
/// Must contain at least two characters and start with a letter.
/// Either <see cref="Name" /> or <see cref="ShortName" /> must be set.
/// All options in a command must have unique names (comparison IS NOT case-sensitive).
/// </remarks>
public string? Name { get; } public string? Name { get; }
/// <summary> /// <summary>
/// Option short name. /// Option short name.
/// </summary> /// </summary>
/// <remarks>
/// Either <see cref="Name" /> or <see cref="ShortName" /> must be set.
/// All options in a command must have unique short names (comparison IS case-sensitive).
/// </remarks>
public char? ShortName { get; } public char? ShortName { get; }
/// <summary> /// <summary>
/// Whether this option is required (default: <c>false</c>). /// Whether this option is required (default: <c>false</c>).
/// If an option is required, the user will get an error if they don't set it. /// If an option is required, the user will get an error when they don't set it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// You can use the <c>required</c> keyword on the property (introduced in C# 11) to implicitly /// You can use the <c>required</c> keyword on the property (introduced in C# 11) to implicitly
@@ -70,28 +64,4 @@ public sealed class CommandOptionAttribute : Attribute
/// has not been explicitly set through command-line arguments. /// has not been explicitly set through command-line arguments.
/// </summary> /// </summary>
public string? EnvironmentVariable { get; set; } public string? EnvironmentVariable { get; set; }
/// <summary>
/// Option description.
/// This is shown to the user in the help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Custom converter used for mapping the raw command-line argument into
/// a value expected by the underlying property.
/// </summary>
/// <remarks>
/// Converter must derive from <see cref="BindingConverter{T}" />.
/// </remarks>
public Type? Converter { get; set; }
/// <summary>
/// Custom validators used for verifying the value of the underlying
/// property, after it has been bound.
/// </summary>
/// <remarks>
/// Validators must derive from <see cref="BindingValidator{T}" />.
/// </remarks>
public Type[] Validators { get; set; } = Array.Empty<Type>();
} }

View File

@@ -1,65 +1,41 @@
using System; using System;
using CliFx.Extensibility; using System.Collections.Generic;
namespace CliFx.Attributes; namespace CliFx.Attributes;
/// <summary> /// <summary>
/// Annotates a property that defines a command parameter. /// Binds a property to a command parameter — a command-line input that is identified by its relative position (order).
/// Higher order means that the parameter appears later, lower order means that it appears earlier.
/// </summary> /// </summary>
/// <remarks>
/// All parameters in a command must have unique order values.
/// If a parameter is bound to a property whose type is a sequence (i.e. implements <see cref="IEnumerable{T}"/>; except <see cref="string" />),
/// then it must have the highest order in the command.
/// Only one sequential parameter is allowed per command.
/// </remarks>
[AttributeUsage(AttributeTargets.Property)] [AttributeUsage(AttributeTargets.Property)]
public sealed class CommandParameterAttribute(int order) : Attribute public class CommandParameterAttribute(int order) : CommandInputAttribute
{ {
/// <summary> /// <summary>
/// Parameter order. /// Parameter order.
/// Higher order means the parameter appears later, lower order means it appears earlier.
/// </summary> /// </summary>
/// <remarks>
/// All parameters in a command must have unique order.
/// Parameter whose type is a non-scalar (e.g. array), must always be the last in order.
/// Only one non-scalar parameter is allowed in a command.
/// </remarks>
public int Order { get; } = order; public int Order { get; } = order;
/// <summary> /// <summary>
/// Whether this parameter is required (default: <c>true</c>). /// Whether this parameter is required (default: <c>true</c>).
/// If a parameter is required, the user will get an error if they don't set it. /// If a parameter is required, the user will get an error when they don't set it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// Parameter marked as non-required must always be the last in order. /// Parameter marked as non-required must have the highest order in the command.
/// Only one non-required parameter is allowed in a command. /// Only one non-required parameter is allowed per command.
/// </remarks> /// </remarks>
public bool IsRequired { get; set; } = true; public bool IsRequired { get; set; } = true;
/// <summary> /// <summary>
/// Parameter name. /// Parameter name, as shown in the help text.
/// This is shown to the user in the help text.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// If this isn't specified, parameter name is inferred from the property name. /// If this isn't specified, parameter name is inferred from the property name.
/// </remarks> /// </remarks>
public string? Name { get; set; } public string? Name { get; set; }
/// <summary>
/// Parameter description.
/// This is shown to the user in the help text.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Custom converter used for mapping the raw command-line argument into
/// a value expected by the underlying property.
/// </summary>
/// <remarks>
/// Converter must derive from <see cref="BindingConverter{T}" />.
/// </remarks>
public Type? Converter { get; set; }
/// <summary>
/// Custom validators used for verifying the value of the underlying
/// property, after it has been bound.
/// </summary>
/// <remarks>
/// Validators must derive from <see cref="BindingValidator{T}" />.
/// </remarks>
public Type[] Validators { get; set; } = Array.Empty<Type>();
} }

View File

@@ -0,0 +1,19 @@
namespace CliFx.Attributes;
/// <summary>
/// Binds a property to the version option of a command.
/// </summary>
/// <remarks>
/// This attribute is applied automatically by the framework and should not need to be used explicitly.
/// </remarks>
public class CommandVersionOptionAttribute : CommandOptionAttribute
{
/// <summary>
/// Initializes an instance of <see cref="CommandVersionOptionAttribute" />.
/// </summary>
public CommandVersionOptionAttribute()
: base("version")
{
Description = "Show application version.";
}
}

View File

@@ -2,12 +2,11 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices;
using System.Threading.Tasks; using System.Threading.Tasks;
using CliFx.Exceptions; using CliFx.Exceptions;
using CliFx.Formatting; using CliFx.Formatting;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using CliFx.Input; using CliFx.Parsing;
using CliFx.Schema; using CliFx.Schema;
using CliFx.Utils; using CliFx.Utils;
using CliFx.Utils.Extensions; using CliFx.Utils.Extensions;
@@ -24,8 +23,6 @@ public class CliApplication(
ITypeActivator typeActivator ITypeActivator typeActivator
) )
{ {
private readonly CommandBinder _commandBinder = new(typeActivator);
/// <summary> /// <summary>
/// Application metadata. /// Application metadata.
/// </summary> /// </summary>
@@ -36,21 +33,11 @@ public class CliApplication(
/// </summary> /// </summary>
public ApplicationConfiguration Configuration { get; } = configuration; public ApplicationConfiguration Configuration { get; } = configuration;
private bool IsDebugModeEnabled(CommandInput commandInput) => private bool IsDebugModeEnabled(CommandArguments commandArguments) =>
Configuration.IsDebugModeAllowed && commandInput.IsDebugDirectiveSpecified; Configuration.IsDebugModeAllowed && commandArguments.IsDebugDirectiveSpecified;
private bool IsPreviewModeEnabled(CommandInput commandInput) => private bool IsPreviewModeEnabled(CommandArguments commandArguments) =>
Configuration.IsPreviewModeAllowed && commandInput.IsPreviewDirectiveSpecified; Configuration.IsPreviewModeAllowed && commandArguments.IsPreviewDirectiveSpecified;
private bool ShouldShowHelpText(CommandSchema commandSchema, CommandInput commandInput) =>
commandSchema.IsHelpOptionAvailable && commandInput.IsHelpOptionSpecified
||
// Show help text also if the fallback default command is executed without any arguments
commandSchema == FallbackDefaultCommand.Schema
&& !commandInput.HasArguments;
private bool ShouldShowVersionText(CommandSchema commandSchema, CommandInput commandInput) =>
commandSchema.IsVersionOptionAvailable && commandInput.IsVersionOptionSpecified;
private async ValueTask PromptDebuggerAsync() private async ValueTask PromptDebuggerAsync()
{ {
@@ -70,7 +57,8 @@ public class CliApplication(
private async ValueTask<int> RunAsync( private async ValueTask<int> RunAsync(
ApplicationSchema applicationSchema, ApplicationSchema applicationSchema,
CommandInput commandInput CommandArguments commandArguments,
IReadOnlyDictionary<string, string?> environmentVariables
) )
{ {
// Console colors may have already been overridden by the parent process, // Console colors may have already been overridden by the parent process,
@@ -78,26 +66,26 @@ public class CliApplication(
console.ResetColor(); console.ResetColor();
// Handle the debug directive // Handle the debug directive
if (IsDebugModeEnabled(commandInput)) if (IsDebugModeEnabled(commandArguments))
{ {
await PromptDebuggerAsync(); await PromptDebuggerAsync();
} }
// Handle the preview directive // Handle the preview directive
if (IsPreviewModeEnabled(commandInput)) if (IsPreviewModeEnabled(commandArguments))
{ {
console.WriteCommandInput(commandInput); console.WriteCommandInput(commandArguments);
return 0; return 0;
} }
// Try to get the command schema that matches the input // Try to get the command schema that matches the input
var commandSchema = var command =
( (
!string.IsNullOrWhiteSpace(commandInput.CommandName) !string.IsNullOrWhiteSpace(commandArguments.CommandName)
// If the command name is specified, try to find the command by name. // If the command name is specified, try to find the command by name.
// This should always succeed, because the input parsing relies on // This should always succeed, because the input parsing relies on
// the list of available command names. // the list of available command names.
? applicationSchema.TryFindCommand(commandInput.CommandName) ? applicationSchema.TryFindCommand(commandArguments.CommandName)
// Otherwise, try to find the default command // Otherwise, try to find the default command
: applicationSchema.TryFindDefaultCommand() : applicationSchema.TryFindDefaultCommand()
) )
@@ -108,42 +96,48 @@ public class CliApplication(
// Initialize an instance of the command type // Initialize an instance of the command type
var commandInstance = var commandInstance =
commandSchema == FallbackDefaultCommand.Schema command == FallbackDefaultCommand.Schema
? new FallbackDefaultCommand() // bypass the activator ? new FallbackDefaultCommand() // bypass the activator
: typeActivator.CreateInstance<ICommand>(commandSchema.Type); : typeActivator.CreateInstance<ICommand>(command.Type);
// Assemble the help context // Assemble the help context
var helpContext = new HelpContext( var helpContext = new HelpContext(
Metadata, Metadata,
applicationSchema, applicationSchema,
commandSchema, command,
commandSchema.GetValues(commandInstance) command.GetValues(commandInstance)
); );
// Handle the help option
if (ShouldShowHelpText(commandSchema, commandInput))
{
console.WriteHelpText(helpContext);
return 0;
}
// Handle the version option
if (ShouldShowVersionText(commandSchema, commandInput))
{
console.WriteLine(Metadata.Version);
return 0;
}
// Starting from this point, we may produce exceptions that are meant for the // Starting from this point, we may produce exceptions that are meant for the
// end-user of the application (i.e. invalid input, command exception, etc). // end-user of the application (i.e. invalid input, command exception, etc).
// Catch these exceptions here, print them to the console, and don't let them // Catch these exceptions here, print them to the console, and don't let them
// propagate further. // propagate further.
try try
{ {
// Bind and execute the command // Activate the command instance with the provided user input
_commandBinder.Bind(commandInput, commandSchema, commandInstance); command.Activate(commandInstance, commandArguments, environmentVariables);
await commandInstance.ExecuteAsync(console);
// Handle the version option
if (commandInstance is ICommandWithVersionOption { IsVersionRequested: true })
{
console.WriteLine(Metadata.Version);
return 0;
}
// Handle the help option
if (
commandInstance
is ICommandWithHelpOption { IsHelpRequested: true }
// Fallback default command always shows help, even if the option is not specified
or FallbackDefaultCommand
)
{
console.WriteHelpText(helpContext);
return 0;
}
// Execute the command
await commandInstance.ExecuteAsync(console);
return 0; return 0;
} }
catch (CliFxException ex) catch (CliFxException ex)
@@ -170,20 +164,19 @@ public class CliApplication(
/// </remarks> /// </remarks>
public async ValueTask<int> RunAsync( public async ValueTask<int> RunAsync(
IReadOnlyList<string> commandLineArguments, IReadOnlyList<string> commandLineArguments,
IReadOnlyDictionary<string, string> environmentVariables IReadOnlyDictionary<string, string?> environmentVariables
) )
{ {
try try
{ {
var applicationSchema = ApplicationSchema.Resolve(Configuration.CommandTypes); return await RunAsync(
Configuration.Schema,
var commandInput = CommandInput.Parse( CommandArguments.Parse(
commandLineArguments, commandLineArguments,
environmentVariables, Configuration.Schema.GetCommandNames()
applicationSchema.GetCommandNames() ),
environmentVariables
); );
return await RunAsync(applicationSchema, commandInput);
} }
// To prevent the app from showing the annoying troubleshooting dialog on Windows, // To prevent the app from showing the annoying troubleshooting dialog on Windows,
// we handle all exceptions ourselves and print them to the console. // we handle all exceptions ourselves and print them to the console.
@@ -211,7 +204,7 @@ public class CliApplication(
commandLineArguments, commandLineArguments,
Environment Environment
.GetEnvironmentVariables() .GetEnvironmentVariables()
.ToDictionary<string, string>(StringComparer.Ordinal) .ToDictionary<string, string?>(StringComparer.Ordinal)
); );
/// <summary> /// <summary>

View File

@@ -1,9 +1,8 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Reflection;
using CliFx.Attributes;
using CliFx.Infrastructure; using CliFx.Infrastructure;
using CliFx.Schema; using CliFx.Schema;
using CliFx.Utils; using CliFx.Utils;
@@ -16,7 +15,7 @@ namespace CliFx;
/// </summary> /// </summary>
public partial class CliApplicationBuilder public partial class CliApplicationBuilder
{ {
private readonly HashSet<Type> _commandTypes = []; private readonly HashSet<CommandSchema> _commands = [];
private bool _isDebugModeAllowed = true; private bool _isDebugModeAllowed = true;
private bool _isPreviewModeAllowed = true; private bool _isPreviewModeAllowed = true;
@@ -27,74 +26,30 @@ public partial class CliApplicationBuilder
private IConsole? _console; private IConsole? _console;
private ITypeActivator? _typeActivator; private ITypeActivator? _typeActivator;
/// <summary> // TODO:
/// Adds a command to the application. // The source generator should generate an internal extension method for the builder called
/// </summary> // AddCommandsFromThisAssembly() that would add all command types from the assembly where the builder is used.
public CliApplicationBuilder AddCommand(Type commandType)
{
_commandTypes.Add(commandType);
return this;
}
/// <summary> /// <summary>
/// Adds a command to the application. /// Adds a command to the application.
/// </summary> /// </summary>
public CliApplicationBuilder AddCommand<TCommand>() public CliApplicationBuilder AddCommand(CommandSchema command)
where TCommand : ICommand => AddCommand(typeof(TCommand)); {
_commands.Add(command);
return this;
}
/// <summary> /// <summary>
/// Adds multiple commands to the application. /// Adds multiple commands to the application.
/// </summary> /// </summary>
public CliApplicationBuilder AddCommands(IEnumerable<Type> commandTypes) public CliApplicationBuilder AddCommands(IReadOnlyList<CommandSchema> commands)
{ {
foreach (var commandType in commandTypes) foreach (var command in commands)
AddCommand(commandType); AddCommand(command);
return this; return this;
} }
/// <summary>
/// Adds commands from the specified assembly to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFrom(Assembly commandAssembly)
{
foreach (
var commandType in commandAssembly.ExportedTypes.Where(CommandSchema.IsCommandType)
)
AddCommand(commandType);
return this;
}
/// <summary>
/// Adds commands from the specified assemblies to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFrom(IEnumerable<Assembly> commandAssemblies)
{
foreach (var commandAssembly in commandAssemblies)
AddCommandsFrom(commandAssembly);
return this;
}
/// <summary>
/// Adds commands from the calling assembly to the application.
/// </summary>
/// <remarks>
/// This method looks for public non-abstract classes that implement <see cref="ICommand" />
/// and are annotated by <see cref="CommandAttribute" />.
/// </remarks>
public CliApplicationBuilder AddCommandsFromThisAssembly() =>
AddCommandsFrom(Assembly.GetCallingAssembly());
/// <summary> /// <summary>
/// Specifies whether debug mode (enabled with the [debug] directive) is allowed in the application. /// Specifies whether debug mode (enabled with the [debug] directive) is allowed in the application.
/// </summary> /// </summary>
@@ -189,15 +144,6 @@ public partial class CliApplicationBuilder
// Null returns are handled by DelegateTypeActivator // Null returns are handled by DelegateTypeActivator
UseTypeActivator(serviceProvider.GetService!); UseTypeActivator(serviceProvider.GetService!);
/// <summary>
/// Configures the application to use the specified service provider for activating types.
/// This method takes a delegate that receives the list of all added command types, so that you can
/// easily register them with the service provider.
/// </summary>
public CliApplicationBuilder UseTypeActivator(
Func<IReadOnlyList<Type>, IServiceProvider> getServiceProvider
) => UseTypeActivator(getServiceProvider(_commandTypes.ToArray()));
/// <summary> /// <summary>
/// Creates a configured instance of <see cref="CliApplication" />. /// Creates a configured instance of <see cref="CliApplication" />.
/// </summary> /// </summary>
@@ -211,7 +157,7 @@ public partial class CliApplicationBuilder
); );
var configuration = new ApplicationConfiguration( var configuration = new ApplicationConfiguration(
_commandTypes.ToArray(), new ApplicationSchema(_commands.ToArray()),
_isDebugModeAllowed, _isDebugModeAllowed,
_isPreviewModeAllowed _isPreviewModeAllowed
); );
@@ -241,15 +187,17 @@ public partial class CliApplicationBuilder
return entryAssemblyName; return entryAssemblyName;
} }
[UnconditionalSuppressMessage(
"SingleFile",
"IL3000:Avoid accessing Assembly file path when publishing as a single file",
Justification = "The file path is checked to ensure the assembly location is available."
)]
private static string GetDefaultExecutableName() private static string GetDefaultExecutableName()
{ {
var entryAssemblyFilePath = EnvironmentEx.EntryAssembly?.Location;
var processFilePath = EnvironmentEx.ProcessPath; var processFilePath = EnvironmentEx.ProcessPath;
if ( // Process file path should generally always be available
string.IsNullOrWhiteSpace(entryAssemblyFilePath) if (string.IsNullOrWhiteSpace(processFilePath))
|| string.IsNullOrWhiteSpace(processFilePath)
)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
"Failed to infer the default application executable name. " "Failed to infer the default application executable name. "
@@ -257,15 +205,22 @@ public partial class CliApplicationBuilder
); );
} }
// If the process path matches the entry assembly path, it's a legacy .NET Framework app var entryAssemblyFilePath = EnvironmentEx.EntryAssembly?.Location;
// or a self-contained .NET Core app.
// Single file application: entry assembly is not on disk and doesn't have a file path
if (string.IsNullOrWhiteSpace(entryAssemblyFilePath))
{
return Path.GetFileNameWithoutExtension(processFilePath);
}
// Legacy .NET Framework application: entry assembly has the same file path as the process
if (PathEx.AreEqual(entryAssemblyFilePath, processFilePath)) if (PathEx.AreEqual(entryAssemblyFilePath, processFilePath))
{ {
return Path.GetFileNameWithoutExtension(entryAssemblyFilePath); return Path.GetFileNameWithoutExtension(entryAssemblyFilePath);
} }
// If the process path has the same name and parent directory as the entry assembly path, // .NET Core application launched through the native application host:
// but different extension, it's a framework-dependent .NET Core app launched through the apphost. // entry assembly has the same file path as the process, but with a different extension.
if ( if (
PathEx.AreEqual(Path.ChangeExtension(entryAssemblyFilePath, "exe"), processFilePath) PathEx.AreEqual(Path.ChangeExtension(entryAssemblyFilePath, "exe"), processFilePath)
|| PathEx.AreEqual( || PathEx.AreEqual(
@@ -277,7 +232,7 @@ public partial class CliApplicationBuilder
return Path.GetFileNameWithoutExtension(entryAssemblyFilePath); return Path.GetFileNameWithoutExtension(entryAssemblyFilePath);
} }
// Otherwise, it's a framework-dependent .NET Core app launched through the .NET CLI // .NET Core application launched through the .NET CLI
return "dotnet " + Path.GetFileName(entryAssemblyFilePath); return "dotnet " + Path.GetFileName(entryAssemblyFilePath);
} }

View File

@@ -1,10 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0</TargetFrameworks> <TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0</TargetFrameworks>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<IsTrimmable
Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net6.0'))"
>true</IsTrimmable
>
<IsAotCompatible
Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net7.0'))"
>true</IsAotCompatible
>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<Authors>$(Company)</Authors> <Authors>$(Company)</Authors>
<Description>Class-first framework for building command-line interfaces</Description> <Description>Class-first framework for building command-line interfaces</Description>
@@ -15,31 +21,25 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression> <PackageLicenseExpression>MIT</PackageLicenseExpression>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<None Include="../favicon.png" Pack="true" PackagePath="" Visible="false" /> <None Include="../favicon.png" Pack="true" PackagePath="" Visible="false" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CSharpier.MsBuild" Version="0.30.4" PrivateAssets="all" /> <PackageReference Include="CSharpier.MsBuild" Version="1.1.2" PrivateAssets="all" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="all" /> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0" PrivateAssets="all" />
<PackageReference Include="PolyShim" Version="1.14.0" PrivateAssets="all" /> <PackageReference Include="PolyShim" Version="1.15.0" PrivateAssets="all" />
<PackageReference Include="System.Threading.Tasks.Extensions" Version="4.6.0" Condition="'$(TargetFramework)' == 'netstandard2.0'" /> <PackageReference
Include="System.Threading.Tasks.Extensions"
Version="4.6.3"
Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'netstandard2.1'))"
/>
</ItemGroup> </ItemGroup>
<!-- Embed the analyzer inside the package --> <!-- Embed the analyzer inside the package -->
<ItemGroup> <ItemGroup>
<ProjectReference Include="../CliFx.Analyzers/CliFx.Analyzers.csproj" ReferenceOutputAssembly="false" OutputItemType="analyzer" /> <ProjectReference
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/CliFx.Analyzers.deps.json" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> Include="..\CliFx.SourceGeneration\CliFx.SourceGeneration.csproj"
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/CliFx.Analyzers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> ReferenceOutputAssembly="false"
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Buffers.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> OutputItemType="analyzer"
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Collections.Immutable.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Memory.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Numerics.Vectors.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Reflection.Metadata.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Runtime.CompilerServices.Unsafe.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Text.Encoding.CodePages.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
<None Include="../CliFx.Analyzers/bin/$(Configuration)/netstandard2.0/System.Threading.Tasks.Extensions.dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup> </ItemGroup>
</Project>
</Project>

View File

@@ -1,395 +0,0 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using CliFx.Exceptions;
using CliFx.Extensibility;
using CliFx.Infrastructure;
using CliFx.Input;
using CliFx.Schema;
using CliFx.Utils.Extensions;
namespace CliFx;
internal class CommandBinder(ITypeActivator typeActivator)
{
private readonly IFormatProvider _formatProvider = CultureInfo.InvariantCulture;
private object? ConvertSingle(IMemberSchema memberSchema, string? rawValue, Type targetType)
{
// Custom converter
if (memberSchema.ConverterType is not null)
{
var converter = typeActivator.CreateInstance<IBindingConverter>(
memberSchema.ConverterType
);
return converter.Convert(rawValue);
}
// Assignable from a string (e.g. string itself, object, etc)
if (targetType.IsAssignableFrom(typeof(string)))
{
return rawValue;
}
// Special case for bool
if (targetType == typeof(bool))
{
return string.IsNullOrWhiteSpace(rawValue) || bool.Parse(rawValue);
}
// Special case for DateTimeOffset
if (targetType == typeof(DateTimeOffset))
{
// Null reference exception will be handled upstream
return DateTimeOffset.Parse(rawValue!, _formatProvider);
}
// Special case for TimeSpan
if (targetType == typeof(TimeSpan))
{
// Null reference exception will be handled upstream
return TimeSpan.Parse(rawValue!, _formatProvider);
}
// Enum
if (targetType.IsEnum)
{
// Null reference exception will be handled upstream
return Enum.Parse(targetType, rawValue!, true);
}
// Convertible primitives (int, double, char, etc)
if (targetType.Implements(typeof(IConvertible)))
{
return Convert.ChangeType(rawValue, targetType, _formatProvider);
}
// Nullable<T>
var nullableUnderlyingType = targetType.TryGetNullableUnderlyingType();
if (nullableUnderlyingType is not null)
{
return !string.IsNullOrWhiteSpace(rawValue)
? ConvertSingle(memberSchema, rawValue, nullableUnderlyingType)
: null;
}
// String-constructable (FileInfo, etc)
var stringConstructor = targetType.GetConstructor([typeof(string)]);
if (stringConstructor is not null)
{
return stringConstructor.Invoke([rawValue]);
}
// String-parseable (with IFormatProvider)
var parseMethodWithFormatProvider = targetType.TryGetStaticParseMethod(true);
if (parseMethodWithFormatProvider is not null)
{
return parseMethodWithFormatProvider.Invoke(null, [rawValue, _formatProvider]);
}
// String-parseable (without IFormatProvider)
var parseMethod = targetType.TryGetStaticParseMethod();
if (parseMethod is not null)
{
return parseMethod.Invoke(null, [rawValue]);
}
throw CliFxException.InternalError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
There is no known way to convert a string value into an instance of type `{targetType.FullName}`.
To fix this, either change the property to use a supported type or configure a custom converter.
"""
);
}
private object? ConvertMultiple(
IMemberSchema memberSchema,
IReadOnlyList<string> rawValues,
Type targetEnumerableType,
Type targetElementType
)
{
var array = rawValues
.Select(v => ConvertSingle(memberSchema, v, targetElementType))
.ToNonGenericArray(targetElementType);
var arrayType = array.GetType();
// Assignable from an array (T[], IReadOnlyList<T>, etc)
if (targetEnumerableType.IsAssignableFrom(arrayType))
{
return array;
}
// Array-constructable (List<T>, HashSet<T>, etc)
var arrayConstructor = targetEnumerableType.GetConstructor([arrayType]);
if (arrayConstructor is not null)
{
return arrayConstructor.Invoke([array]);
}
throw CliFxException.InternalError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has an unsupported underlying property type.
There is no known way to convert an array of `{targetElementType.FullName}` into an instance of type `{targetEnumerableType.FullName}`.
To fix this, change the property to use a type which can be assigned from an array or a type which has a constructor that accepts an array.
"""
);
}
private object? ConvertMember(IMemberSchema memberSchema, IReadOnlyList<string> rawValues)
{
try
{
// Non-scalar
var enumerableUnderlyingType =
memberSchema.Property.Type.TryGetEnumerableUnderlyingType();
if (
enumerableUnderlyingType is not null
&& memberSchema.Property.Type != typeof(string)
)
{
return ConvertMultiple(
memberSchema,
rawValues,
memberSchema.Property.Type,
enumerableUnderlyingType
);
}
// Scalar
if (rawValues.Count <= 1)
{
return ConvertSingle(
memberSchema,
rawValues.SingleOrDefault(),
memberSchema.Property.Type
);
}
}
catch (Exception ex) when (ex is not CliFxException) // don't wrap CliFxException
{
// We use reflection-based invocation which can throw TargetInvocationException.
// Unwrap those exceptions to provide a more user-friendly error message.
var errorMessage = ex is TargetInvocationException invokeEx
? invokeEx.InnerException?.Message ?? invokeEx.Message
: ex.Message;
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} cannot be set from the provided argument(s):
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
Error: {errorMessage}
""",
ex
);
}
// Mismatch (scalar but too many values)
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} expects a single argument, but provided with multiple:
{rawValues.Select(v => '<' + v + '>').JoinToString(" ")}
"""
);
}
private void ValidateMember(IMemberSchema memberSchema, object? convertedValue)
{
var errors = new List<BindingValidationError>();
foreach (var validatorType in memberSchema.ValidatorTypes)
{
var validator = typeActivator.CreateInstance<IBindingValidator>(validatorType);
var error = validator.Validate(convertedValue);
if (error is not null)
errors.Add(error);
}
if (errors.Any())
{
throw CliFxException.UserError(
$"""
{memberSchema.GetKind()} {memberSchema.GetFormattedIdentifier()} has been provided with an invalid value.
Error(s):
{errors.Select(e => "- " + e.Message).JoinToString(Environment.NewLine)}
"""
);
}
}
private void BindMember(
IMemberSchema memberSchema,
ICommand commandInstance,
IReadOnlyList<string> rawValues
)
{
var convertedValue = ConvertMember(memberSchema, rawValues);
ValidateMember(memberSchema, convertedValue);
memberSchema.Property.SetValue(commandInstance, convertedValue);
}
private void BindParameters(
CommandInput commandInput,
CommandSchema commandSchema,
ICommand commandInstance
)
{
// Ensure there are no unexpected parameters and that all parameters are provided
var remainingParameterInputs = commandInput.Parameters.ToList();
var remainingRequiredParameterSchemas = commandSchema
.Parameters.Where(p => p.IsRequired)
.ToList();
var position = 0;
foreach (var parameterSchema in commandSchema.Parameters.OrderBy(p => p.Order))
{
// Break when there are no remaining inputs
if (position >= commandInput.Parameters.Count)
break;
// Scalar: take one input at the current position
if (parameterSchema.Property.IsScalar())
{
var parameterInput = commandInput.Parameters[position];
BindMember(parameterSchema, commandInstance, [parameterInput.Value]);
position++;
remainingParameterInputs.Remove(parameterInput);
}
// Non-scalar: take all remaining inputs starting from the current position
else
{
var parameterInputs = commandInput.Parameters.Skip(position).ToArray();
BindMember(
parameterSchema,
commandInstance,
parameterInputs.Select(p => p.Value).ToArray()
);
position += parameterInputs.Length;
remainingParameterInputs.RemoveRange(parameterInputs);
}
remainingRequiredParameterSchemas.Remove(parameterSchema);
}
if (remainingParameterInputs.Any())
{
throw CliFxException.UserError(
$"""
Unexpected parameter(s):
{remainingParameterInputs.Select(p => p.GetFormattedIdentifier()).JoinToString(" ")}
"""
);
}
if (remainingRequiredParameterSchemas.Any())
{
throw CliFxException.UserError(
$"""
Missing required parameter(s):
{remainingRequiredParameterSchemas
.Select(p => p.GetFormattedIdentifier())
.JoinToString(" ")}
"""
);
}
}
private void BindOptions(
CommandInput commandInput,
CommandSchema commandSchema,
ICommand commandInstance
)
{
// Ensure there are no unrecognized options and that all required options are set
var remainingOptionInputs = commandInput.Options.ToList();
var remainingRequiredOptionSchemas = commandSchema
.Options.Where(o => o.IsRequired)
.ToList();
foreach (var optionSchema in commandSchema.Options)
{
var optionInputs = commandInput
.Options.Where(o => optionSchema.MatchesIdentifier(o.Identifier))
.ToArray();
var environmentVariableInput = commandInput.EnvironmentVariables.FirstOrDefault(e =>
optionSchema.MatchesEnvironmentVariable(e.Name)
);
// Direct input
if (optionInputs.Any())
{
var rawValues = optionInputs.SelectMany(o => o.Values).ToArray();
BindMember(optionSchema, commandInstance, rawValues);
// Required options need at least one value to be set
if (rawValues.Any())
remainingRequiredOptionSchemas.Remove(optionSchema);
}
// Environment variable
else if (environmentVariableInput is not null)
{
var rawValues = optionSchema.Property.IsScalar()
? [environmentVariableInput.Value]
: environmentVariableInput.SplitValues();
BindMember(optionSchema, commandInstance, rawValues);
// Required options need at least one value to be set
if (rawValues.Any())
remainingRequiredOptionSchemas.Remove(optionSchema);
}
// No input, skip
else
{
continue;
}
remainingOptionInputs.RemoveRange(optionInputs);
}
if (remainingOptionInputs.Any())
{
throw CliFxException.UserError(
$"""
Unrecognized option(s):
{remainingOptionInputs.Select(o => o.GetFormattedIdentifier()).JoinToString(", ")}
"""
);
}
if (remainingRequiredOptionSchemas.Any())
{
throw CliFxException.UserError(
$"""
Missing required option(s):
{remainingRequiredOptionSchemas
.Select(o => o.GetFormattedIdentifier())
.JoinToString(", ")}
"""
);
}
}
public void Bind(
CommandInput commandInput,
CommandSchema commandSchema,
ICommand commandInstance
)
{
BindParameters(commandInput, commandSchema, commandInstance);
BindOptions(commandInput, commandSchema, commandInstance);
}
}

View File

@@ -3,7 +3,7 @@
namespace CliFx.Exceptions; namespace CliFx.Exceptions;
/// <summary> /// <summary>
/// Exception thrown when there is an error during application execution. /// Exception thrown within <see cref="CliFx" />.
/// </summary> /// </summary>
public partial class CliFxException( public partial class CliFxException(
string message, string message,
@@ -40,7 +40,7 @@ public partial class CliFxException
Exception? innerException = null Exception? innerException = null
) => new(message, DefaultExitCode, false, innerException); ) => new(message, DefaultExitCode, false, innerException);
// User errors are typically caused by invalid input and they're meant for the end-user, // User errors are typically caused by invalid input and are meant for the end-user,
// so we want to show help. // so we want to show help.
internal static CliFxException UserError(string message, Exception? innerException = null) => internal static CliFxException UserError(string message, Exception? innerException = null) =>
new(message, DefaultExitCode, true, innerException); new(message, DefaultExitCode, true, innerException);

Some files were not shown because too many files have changed in this diff Show More