diff --git a/System.CommandLine.sln b/System.CommandLine.sln index bb83d4f820..ed35369bb2 100644 --- a/System.CommandLine.sln +++ b/System.CommandLine.sln @@ -54,9 +54,15 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Benchmar EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Hosting", "src\System.CommandLine.Hosting\System.CommandLine.Hosting.csproj", "{644C4B4A-4A32-4307-9F71-C3BF901FFB66}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "System.CommandLine.Hosting.Tests", "src\System.CommandLine.Hosting.Tests\System.CommandLine.Hosting.Tests.csproj", "{39483140-BC26-4CAD-BBAE-3DC76C2F16CF}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Hosting.Tests", "src\System.CommandLine.Hosting.Tests\System.CommandLine.Hosting.Tests.csproj", "{39483140-BC26-4CAD-BBAE-3DC76C2F16CF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HostingPlayground", "samples\HostingPlayground\HostingPlayground.csproj", "{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HostingPlayground", "samples\HostingPlayground\HostingPlayground.csproj", "{0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Localization", "src\System.CommandLine.Localization\System.CommandLine.Localization.csproj", "{9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "System.CommandLine.Localization.Tests", "src\System.CommandLine.Localization.Tests\System.CommandLine.Localization.Tests.csproj", "{182581D0-4AAB-49EE-8713-D890A6401DCF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LocalizationPlayground", "samples\LocalizationPlayground\LocalizationPlayground.csproj", "{8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -236,6 +242,42 @@ Global {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x64.Build.0 = Release|Any CPU {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.ActiveCfg = Release|Any CPU {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906}.Release|x86.Build.0 = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|x64.ActiveCfg = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|x64.Build.0 = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|x86.ActiveCfg = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Debug|x86.Build.0 = Debug|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|Any CPU.Build.0 = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|x64.ActiveCfg = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|x64.Build.0 = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|x86.ActiveCfg = Release|Any CPU + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF}.Release|x86.Build.0 = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|x64.ActiveCfg = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|x64.Build.0 = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|x86.ActiveCfg = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Debug|x86.Build.0 = Debug|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|Any CPU.Build.0 = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|x64.ActiveCfg = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|x64.Build.0 = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|x86.ActiveCfg = Release|Any CPU + {182581D0-4AAB-49EE-8713-D890A6401DCF}.Release|x86.Build.0 = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|x64.ActiveCfg = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|x64.Build.0 = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|x86.ActiveCfg = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Debug|x86.Build.0 = Debug|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|Any CPU.Build.0 = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|x64.ActiveCfg = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|x64.Build.0 = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|x86.ActiveCfg = Release|Any CPU + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -255,6 +297,9 @@ Global {644C4B4A-4A32-4307-9F71-C3BF901FFB66} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {39483140-BC26-4CAD-BBAE-3DC76C2F16CF} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} {0BF6958D-9EE3-4623-B3D6-4DA77EAC1906} = {6749FB3E-39DE-4321-A39E-525278E9408D} + {9FD1BB47-F1B9-48A2-BD54-C324357C7BEF} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {182581D0-4AAB-49EE-8713-D890A6401DCF} = {E5B1EC71-0FC4-4FAA-9C65-32D5016FBC45} + {8B62D16B-1CB0-40E6-810F-2FC6F8215BE0} = {6749FB3E-39DE-4321-A39E-525278E9408D} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {5C159F93-800B-49E7-9905-EE09F8B8434A} diff --git a/samples/LocalizationPlayground/.gitignore b/samples/LocalizationPlayground/.gitignore new file mode 100644 index 0000000000..90dce1bc59 --- /dev/null +++ b/samples/LocalizationPlayground/.gitignore @@ -0,0 +1 @@ +xlf \ No newline at end of file diff --git a/samples/LocalizationPlayground/CultureEnvironmentCommandLineExtensions.cs b/samples/LocalizationPlayground/CultureEnvironmentCommandLineExtensions.cs new file mode 100644 index 0000000000..b908097796 --- /dev/null +++ b/samples/LocalizationPlayground/CultureEnvironmentCommandLineExtensions.cs @@ -0,0 +1,30 @@ +using System; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.Globalization; +using System.Threading; + +namespace LocalizationPlayground +{ + internal static class CultureEnvironmentCommandLineExtensions + { + internal static CommandLineBuilder UseCultureEnvironment( + this CommandLineBuilder builder) + { + return builder.UseMiddleware(async (context, next) => + { + if (Environment.GetEnvironmentVariable("DOTNET_SYSTEM_GLOBALIZATION_CULTURE") is string culture && + !string.IsNullOrEmpty(culture)) + CultureInfo.CurrentCulture = CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(culture); + + await next(context).ConfigureAwait(false); + + if (context.InvocationResult is IInvocationResult innerResult) + { + var execCtx = ExecutionContext.Capture(); + context.InvocationResult = new ExecutionContextRestoringInvocationResult(execCtx, innerResult); + } + }, MiddlewareOrder.ExceptionHandler); + } + } +} diff --git a/samples/LocalizationPlayground/ExecutionContextRestoringInvocationResult.cs b/samples/LocalizationPlayground/ExecutionContextRestoringInvocationResult.cs new file mode 100644 index 0000000000..dbe9ff49a1 --- /dev/null +++ b/samples/LocalizationPlayground/ExecutionContextRestoringInvocationResult.cs @@ -0,0 +1,35 @@ +using System; +using System.CommandLine.Invocation; +using System.Threading; + +namespace LocalizationPlayground +{ + internal class ExecutionContextRestoringInvocationResult : IInvocationResult + { + private static readonly ContextCallback executionContextApplyCallback = state => + { + var (@this, context) = (ValueTuple<IInvocationResult, InvocationContext>)state!; + @this.Apply(context); + }; + + private readonly ExecutionContext? executionContext; + private readonly IInvocationResult innerResult; + + public ExecutionContextRestoringInvocationResult(ExecutionContext? executionContext, IInvocationResult innerResult) + { + this.executionContext = executionContext; + this.innerResult = innerResult; + } + + public void Apply(InvocationContext context) + { + if (executionContext is null) + innerResult.Apply(context); + else + ExecutionContext.Run(executionContext, + executionContextApplyCallback, + (innerResult, context) + ); + } + } +} diff --git a/samples/LocalizationPlayground/LocalizationPlayground.csproj b/samples/LocalizationPlayground/LocalizationPlayground.csproj new file mode 100644 index 0000000000..f305902dd4 --- /dev/null +++ b/samples/LocalizationPlayground/LocalizationPlayground.csproj @@ -0,0 +1,14 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <Nullable>enable</Nullable> + <OutputType>Exe</OutputType> + <TargetFramework>netcoreapp3.1</TargetFramework> + </PropertyGroup> + + <ItemGroup> + <ProjectReference Include="..\..\src\System.CommandLine\System.CommandLine.csproj" /> + <ProjectReference Include="..\..\src\System.CommandLine.Localization\System.CommandLine.Localization.csproj" /> + </ItemGroup> + +</Project> diff --git a/samples/LocalizationPlayground/Program.cs b/samples/LocalizationPlayground/Program.cs new file mode 100644 index 0000000000..92f8e329a0 --- /dev/null +++ b/samples/LocalizationPlayground/Program.cs @@ -0,0 +1,74 @@ +using System; +using System.CommandLine; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Localization; +using System.CommandLine.Parsing; +using System.Globalization; +using System.Threading.Tasks; +using Microsoft.Extensions.Localization; + +namespace LocalizationPlayground +{ + public static class Program + { + public static Task<int> Main(string[] args) + { + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; + var parser = new CommandLineBuilder( + new RootCommand + { + Description = "Playground for localized CommandLine", + Handler = CommandHandler.Create((int count, string name, InvocationContext invocation, IStringLocalizerFactory localizerFactory) => + { + var cult = CultureInfo.CurrentUICulture; + var germanResourceNames = typeof(Program).Assembly + .GetSatelliteAssembly(CultureInfo.GetCultureInfo("de")) + .GetManifestResourceNames(); + + var localizer = localizerFactory.Create(typeof(Program)); + var locCultureInfo = localizer.GetString("Current culture: {0}", cult.NativeName); + Console.WriteLine(locCultureInfo); + var locLine = localizer.GetString("Hello {0}!", name); + + var availableStrings = localizer.GetAllStrings(true); + + _ = germanResourceNames; + _ = availableStrings; + + for (int i = 0; i < count; i++) + { + Console.WriteLine(locLine); + } + + Console.WriteLine(); + invocation.InvocationResult = new HelpResult(); + }), + }) + .AddOption(new Option<int>(new[] { "--count", "-c" }, () => 1) + { + Name = "count", + Description = "Count of lines to print", + Argument = + { + Name = "COUNT", + Description = "An integer value", + Arity = ArgumentArity.ZeroOrOne, + } + }) + .AddArgument(new Argument<string>("NAME") + { + Description = "The name to display", + Arity = ArgumentArity.ExactlyOne, + }) + .UseEnvironmentVariableDirective() + .UseDebugDirective() + .UseHelp() + .UseVersionOption() + .UseCultureEnvironment() + .UseLocalization() + .Build(); + return parser.InvokeAsync(args ?? Array.Empty<string>()); + } + } +} diff --git a/samples/LocalizationPlayground/Program.resx b/samples/LocalizationPlayground/Program.resx new file mode 100644 index 0000000000..fbba8b0e68 --- /dev/null +++ b/samples/LocalizationPlayground/Program.resx @@ -0,0 +1,138 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="An integer value" xml:space="preserve"> + <value>An integer value</value> + </data> + <data name="Count of lines to print" xml:space="preserve"> + <value>Count of lines to print</value> + </data> + <data name="Current culture: {0}" xml:space="preserve"> + <value>Current language settings: {0}</value> + </data> + <data name="Hello {0}!" xml:space="preserve"> + <value>Hello {0}!</value> + </data> + <data name="Playground for localized CommandLine" xml:space="preserve"> + <value>Playground for localized command-line applications</value> + </data> + <data name="The name to display" xml:space="preserve"> + <value>The name to display</value> + </data> +</root> \ No newline at end of file diff --git a/samples/LocalizationPlayground/Properties/launchSettings.json b/samples/LocalizationPlayground/Properties/launchSettings.json new file mode 100644 index 0000000000..5b5b00ebf3 --- /dev/null +++ b/samples/LocalizationPlayground/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "LocalizationPlayground": { + "commandName": "Project", + "commandLineArgs": "[env:DOTNET_SYSTEM_GLOBALIZATION_CULTURE=de-DE] -c 3 .NET" + } + } +} \ No newline at end of file diff --git a/samples/LocalizationPlayground/xlf/Program.de.xlf b/samples/LocalizationPlayground/xlf/Program.de.xlf new file mode 100644 index 0000000000..ddaf8fe60d --- /dev/null +++ b/samples/LocalizationPlayground/xlf/Program.de.xlf @@ -0,0 +1,37 @@ +<?xml version="1.0" encoding="utf-8"?> +<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 xliff-core-1.2-transitional.xsd"> + <file datatype="xml" source-language="en" target-language="de" original="../Program.resx"> + <body> + <trans-unit id="An integer value"> + <source>An integer value</source> + <target state="new">Eine Ganzzahl</target> + <note /> + </trans-unit> + <trans-unit id="Count of lines to print"> + <source>Count of lines to print</source> + <target state="new">Anzahl angezeigter Zeilen</target> + <note /> + </trans-unit> + <trans-unit id="Current culture: {0}"> + <source>Current language settings: {0}</source> + <target state="new">Aktuelle Spracheinstellungen: {0}</target> + <note /> + </trans-unit> + <trans-unit id="Hello {0}!"> + <source>Hello {0}!</source> + <target state="new">Hallo {0}!</target> + <note /> + </trans-unit> + <trans-unit id="Playground for localized CommandLine"> + <source>Playground for localized command-line applications</source> + <target state="new">Spielplatz für lokalisierte Kommandozeilen-Programme</target> + <note /> + </trans-unit> + <trans-unit id="The name to display"> + <source>The name to display</source> + <target state="new">Angezeigter Name</target> + <note /> + </trans-unit> + </body> + </file> +</xliff> \ No newline at end of file diff --git a/src/System.CommandLine.Localization.Tests/LocalizationExtensionsTests.cs b/src/System.CommandLine.Localization.Tests/LocalizationExtensionsTests.cs new file mode 100644 index 0000000000..2533724bda --- /dev/null +++ b/src/System.CommandLine.Localization.Tests/LocalizationExtensionsTests.cs @@ -0,0 +1,34 @@ +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.CommandLine.Parsing; +using FluentAssertions; +using Microsoft.Extensions.Localization; +using Xunit; + +namespace System.CommandLine.Localization.Tests +{ + public class LocalizationExtensionsTests + { + [Fact] + public void UseLocalization_registers_IStringLocalizerFactory_to_binding_context() + { + bool asserted = false; + var command = new RootCommand() + { + Handler = CommandHandler.Create((IStringLocalizerFactory localizerFactory) => + { + localizerFactory.Should().NotBeNull(); + + asserted = true; + }), + }; + var parser = new CommandLineBuilder(command) + .UseLocalization() + .Build(); + + parser.InvokeAsync("").ConfigureAwait(false).GetAwaiter().GetResult(); + + asserted.Should().BeTrue(); + } + } +} diff --git a/src/System.CommandLine.Localization.Tests/System.CommandLine.Localization.Tests.csproj b/src/System.CommandLine.Localization.Tests/System.CommandLine.Localization.Tests.csproj new file mode 100644 index 0000000000..541f0a229a --- /dev/null +++ b/src/System.CommandLine.Localization.Tests/System.CommandLine.Localization.Tests.csproj @@ -0,0 +1,33 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <TargetFrameworks>netcoreapp3.1</TargetFrameworks> + <TargetFrameworks Condition="'$(OS)' == 'Windows_NT'">$(TargetFrameworks);net462</TargetFrameworks> + <LangVersion>latest</LangVersion> + <IsPackable>false</IsPackable> + </PropertyGroup> + + <ItemGroup> + <Compile Remove="TestResults\**" /> + <EmbeddedResource Remove="TestResults\**" /> + <None Remove="TestResults\**" /> + </ItemGroup> + + <ItemGroup> + <PackageReference Include="FluentAssertions" Version="5.10.3" /> + </ItemGroup> + + + <ItemGroup Condition="'$(DisableArcade)' == '1'"> + <PackageReference Include="xunit" Version="2.4.1" /> + <PackageReference Include="xunit.runner.visualstudio" Version="2.4.1"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\System.CommandLine.Localization\System.CommandLine.Localization.csproj" /> + </ItemGroup> + +</Project> diff --git a/src/System.CommandLine.Localization/.gitignore b/src/System.CommandLine.Localization/.gitignore new file mode 100644 index 0000000000..90dce1bc59 --- /dev/null +++ b/src/System.CommandLine.Localization/.gitignore @@ -0,0 +1 @@ +xlf \ No newline at end of file diff --git a/src/System.CommandLine.Localization/LocalizationExtensions.cs b/src/System.CommandLine.Localization/LocalizationExtensions.cs new file mode 100644 index 0000000000..2a40244207 --- /dev/null +++ b/src/System.CommandLine.Localization/LocalizationExtensions.cs @@ -0,0 +1,83 @@ +using System.CommandLine.Binding; +using System.CommandLine.Builder; +using System.CommandLine.Invocation; +using System.IO; +using System.Reflection; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace System.CommandLine.Localization +{ + public static class LocalizationExtensions + { + public static CommandLineBuilder UseLocalization( + this CommandLineBuilder builder, Type? resourceSource = null) + { + _ = builder ?? throw new ArgumentNullException(nameof(builder)); + + builder.UseMiddleware((context, next) => + { + var binding = context.BindingContext; + + binding.AddService(serviceProvider => + { + ILoggerFactory? loggerFactory = null; + // If using Generic Host integration + if (GetDynamicLoadedIHostInstance(serviceProvider) is { Interface: Type iHostType, Instance: object iHostInstance }) + { + const BindingFlags getProperty = BindingFlags.Public | BindingFlags.Instance | BindingFlags.GetProperty; + var hostedServices = iHostType.InvokeMember( + "Services", getProperty, Type.DefaultBinder, + iHostInstance, null); + if (hostedServices is IServiceProvider hostedServiceProvider) + { + if (hostedServiceProvider.GetService<IStringLocalizerFactory>() is { } hostedLocalizer) + return hostedLocalizer; + + // Extract logger factory if possible + loggerFactory = hostedServiceProvider.GetService<ILoggerFactory>(); + } + } + + // Construct default localizer + var options = serviceProvider.GetService<IOptions<LocalizationOptions>>() ?? + Options.Create(new LocalizationOptions()); + loggerFactory ??= serviceProvider.GetService<ILoggerFactory>() ?? + Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + return new ResourceManagerStringLocalizerFactory(options, loggerFactory); + + static (Type? Interface, object? Instance) GetDynamicLoadedIHostInstance(IServiceProvider serviceProvider) + { + Assembly? hostingAbstractionAsm = null; + try + { + hostingAbstractionAsm = Assembly.Load("Microsoft.Extensions.Hosting.Abstractions"); + } + catch (Exception) { } + if (hostingAbstractionAsm is null) + return default; + var iHostType = Type.GetType(@"Microsoft.Extensions.Hosting.IHost, Microsoft.Extensions.Hosting.Abstractions"); + if (iHostType is null) + return default; + var iHostInstance = serviceProvider.GetService(iHostType); + return (iHostType, iHostInstance); + } + }); + + return next(context); + }, MiddlewareOrder.ExceptionHandler); + builder.UseHelpBuilder(ctx => + { + var binder = new ModelBinder<LocalizedHelpBuilderFactory>(); + if (!(binder.CreateInstance(ctx) is LocalizedHelpBuilderFactory helpFactory)) + throw new InvalidOperationException("Unable to resolve a localized help builder instance from the binding context."); + return helpFactory.CreateHelpBuilder(resourceSource); + }); + + return builder; + } + } +} diff --git a/src/System.CommandLine.Localization/LocalizedHelpBuilder.cs b/src/System.CommandLine.Localization/LocalizedHelpBuilder.cs new file mode 100644 index 0000000000..12f7609ecd --- /dev/null +++ b/src/System.CommandLine.Localization/LocalizedHelpBuilder.cs @@ -0,0 +1,179 @@ +using System.CommandLine.Help; +using System.Linq; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace System.CommandLine.Localization +{ + public class LocalizedHelpBuilder : HelpBuilder + { + private readonly IStringLocalizer localizer; + private readonly IStringLocalizer helpLocalizer; + + public LocalizedHelpBuilder(IStringLocalizerFactory localizerFactory, + Type resourceSource, IConsole console, int? columnGutter = null, + int? indentationSize = null, int? maxWidth = null) + : base(console, columnGutter, indentationSize, maxWidth) + { + localizerFactory ??= new ResourceManagerStringLocalizerFactory( + Options.Create(new LocalizationOptions()), NullLoggerFactory.Instance); + + localizer = localizerFactory.Create(resourceSource); + helpLocalizer = localizerFactory.Create(GetType()); + + AdditionalArgumentsTitle = GetHelpBuilderLocalizedString( + "DefaultHelpText.AdditionalArguments.Title", + DefaultHelpText.AdditionalArguments.Title); + AdditionalArgumentsDescription = GetHelpBuilderLocalizedString( + "DefaultHelpText.AdditionalArguments.Description", + DefaultHelpText.AdditionalArguments.Description); + ArgumentsTitle = GetHelpBuilderLocalizedString( + "DefaultHelpText.Arguments.Title", + DefaultHelpText.Arguments.Title); + CommandsTitle = GetHelpBuilderLocalizedString( + "DefaultHelpText.Commands.Title", + DefaultHelpText.Commands.Title); + OptionsTitle = GetHelpBuilderLocalizedString( + "DefaultHelpText.Options.Title", + DefaultHelpText.Options.Title); + UsageAdditionalArgumentsText = GetHelpBuilderLocalizedString( + "DefaultHelpText.Usage.AdditionalArguments", + DefaultHelpText.Usage.AdditionalArguments); + UsageCommandText = GetHelpBuilderLocalizedString( + "DefaultHelpText.Usage.Command", + DefaultHelpText.Usage.Command); + UsageOptionsText = GetHelpBuilderLocalizedString( + "DefaultHelpText.Usage.Options", + DefaultHelpText.Usage.Options); + UsageTitle = GetHelpBuilderLocalizedString( + "DefaultHelpText.Usage.Title", + DefaultHelpText.Usage.Title); + } + + public override void Write(ICommand command) + { + base.Write(GetLocalizedCommand(command)); + } + + public override void Write(IOption option) + { + base.Write(GetLocalizedOption(option)); + } + + private Command GetLocalizedCommand(ICommand command) + { + var lcmd = new Command(command.Name); + Localize(lcmd, command); + + foreach (IOption option in command.Options) + { + lcmd.AddOption(GetLocalizedOption(option)); + } + + foreach (IArgument argument in command.Arguments) + { + lcmd.AddArgument(GetLocalizedArgument(argument)); + } + + lcmd.TreatUnmatchedTokensAsErrors = command.TreatUnmatchedTokensAsErrors; + + return lcmd; + } + + private Option GetLocalizedOption(IOption option) + { + var lopt = new Option(option.RawAliases.First()); + Localize(lopt, option); + + lopt.Name = option.Name; + if (!(option.Argument.Arity is { MaximumNumberOfValues: 0, MinimumNumberOfValues: 0 })) + { + lopt.Argument = GetLocalizedArgument(option.Argument); + } + + lopt.IsRequired = option.IsRequired; + + return lopt; + } + + private Argument GetLocalizedArgument(IArgument argument) + { + var larg = new Argument(argument.Name); + Localize(larg, argument); + larg.ArgumentType = argument.ValueType; + larg.Arity = argument.Arity; + if (argument.HasDefaultValue) + { + larg.SetDefaultValueFactory(() => argument.GetDefaultValue()); + } + + larg.AddSuggestions(txtToMatch => argument.GetSuggestions(txtToMatch)!); + + return larg; + } + + private void Localize(Symbol symbol, ISymbol source) + { + if (!string.IsNullOrEmpty(source.Description)) + { + var locDesc = localizer.GetString(source.Description); + if (locDesc.ResourceNotFound) + { + if (source.GetType().Name.Equals("HelpOption", StringComparison.Ordinal)) + { + symbol.Description = GetHelpBuilderLocalizedString( + "HelpOption.Description", + source.Description ?? ""); + } + else if (source.Name.Equals("version", StringComparison.OrdinalIgnoreCase)) + { + symbol.Description = GetHelpBuilderLocalizedString( + "VersionOption.Description", + source.Description ?? ""); + } + } + else + { + symbol.Description = locDesc; + } + } + + foreach (var alias in source.RawAliases) + { + symbol.AddAlias(alias); + } + + symbol.IsHidden = source.IsHidden; + } + + protected override string DefaultValueHint(IArgument argument, bool isSingleArgument = true) + { + if (argument.HasDefaultValue && isSingleArgument && ShouldShowDefaultValueHint(argument)) + { + var locDefault = helpLocalizer.GetString( + $"{nameof(HelpBuilder)}.{nameof(DefaultValueHint)}", + argument.GetDefaultValue()); + if (!(locDefault.ResourceNotFound || string.IsNullOrEmpty(locDefault))) + { + return locDefault; + } + } + + return base.DefaultValueHint(argument, isSingleArgument); + } + + private string GetHelpBuilderLocalizedString(string key, string @default) + { + var localized = helpLocalizer.GetString(key); + string localizedValue = localized; + if (string.IsNullOrEmpty(localizedValue) || + string.Equals(localizedValue, key, StringComparison.Ordinal)) + { + return @default; + } + + return localizedValue; + } + } +} diff --git a/src/System.CommandLine.Localization/LocalizedHelpBuilder.resx b/src/System.CommandLine.Localization/LocalizedHelpBuilder.resx new file mode 100644 index 0000000000..465e59577e --- /dev/null +++ b/src/System.CommandLine.Localization/LocalizedHelpBuilder.resx @@ -0,0 +1,159 @@ +<?xml version="1.0" encoding="utf-8"?> +<root> + <!-- + Microsoft ResX Schema + + Version 2.0 + + The primary goals of this format is to allow a simple XML format + that is mostly human readable. The generation and parsing of the + various data types are done through the TypeConverter classes + associated with the data types. + + Example: + + ... ado.net/XML headers & schema ... + <resheader name="resmimetype">text/microsoft-resx</resheader> + <resheader name="version">2.0</resheader> + <resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader> + <resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader> + <data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data> + <data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data> + <data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64"> + <value>[base64 mime encoded serialized .NET Framework object]</value> + </data> + <data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64"> + <value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value> + <comment>This is a comment</comment> + </data> + + There are any number of "resheader" rows that contain simple + name/value pairs. + + Each data row contains a name, and value. The row also contains a + type or mimetype. Type corresponds to a .NET class that support + text/value conversion through the TypeConverter architecture. + Classes that don't support this are serialized and stored with the + mimetype set. + + The mimetype is used for serialized objects, and tells the + ResXResourceReader how to depersist the object. This is currently not + extensible. For a given mimetype the value must be set accordingly: + + Note - application/x-microsoft.net.object.binary.base64 is the format + that the ResXResourceWriter will generate, however the reader can + read any of the formats listed below. + + mimetype: application/x-microsoft.net.object.binary.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Binary.BinaryFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.soap.base64 + value : The object must be serialized with + : System.Runtime.Serialization.Formatters.Soap.SoapFormatter + : and then encoded with base64 encoding. + + mimetype: application/x-microsoft.net.object.bytearray.base64 + value : The object must be serialized into a byte array + : using a System.ComponentModel.TypeConverter + : and then encoded with base64 encoding. + --> + <xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata"> + <xsd:import namespace="http://www.w3.org/XML/1998/namespace" /> + <xsd:element name="root" msdata:IsDataSet="true"> + <xsd:complexType> + <xsd:choice maxOccurs="unbounded"> + <xsd:element name="metadata"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" /> + </xsd:sequence> + <xsd:attribute name="name" use="required" type="xsd:string" /> + <xsd:attribute name="type" type="xsd:string" /> + <xsd:attribute name="mimetype" type="xsd:string" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="assembly"> + <xsd:complexType> + <xsd:attribute name="alias" type="xsd:string" /> + <xsd:attribute name="name" type="xsd:string" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="data"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + <xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" /> + <xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" /> + <xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" /> + <xsd:attribute ref="xml:space" /> + </xsd:complexType> + </xsd:element> + <xsd:element name="resheader"> + <xsd:complexType> + <xsd:sequence> + <xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" /> + </xsd:sequence> + <xsd:attribute name="name" type="xsd:string" use="required" /> + </xsd:complexType> + </xsd:element> + </xsd:choice> + </xsd:complexType> + </xsd:element> + </xsd:schema> + <resheader name="resmimetype"> + <value>text/microsoft-resx</value> + </resheader> + <resheader name="version"> + <value>2.0</value> + </resheader> + <resheader name="reader"> + <value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <resheader name="writer"> + <value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value> + </resheader> + <data name="DefaultHelpText.AdditionalArguments.Description" xml:space="preserve"> + <value>DefaultHelpText.AdditionalArguments.Description</value> + </data> + <data name="DefaultHelpText.AdditionalArguments.Title" xml:space="preserve"> + <value>DefaultHelpText.AdditionalArguments.Title</value> + </data> + <data name="DefaultHelpText.Arguments.Title" xml:space="preserve"> + <value>DefaultHelpText.Arguments.Title</value> + </data> + <data name="DefaultHelpText.Commands.Title" xml:space="preserve"> + <value>DefaultHelpText.Commands.Title</value> + </data> + <data name="DefaultHelpText.Options.Title" xml:space="preserve"> + <value>DefaultHelpText.Options.Title</value> + </data> + <data name="DefaultHelpText.Usage.AdditionalArguments" xml:space="preserve"> + <value>DefaultHelpText.Usage.AdditionalArguments</value> + </data> + <data name="DefaultHelpText.Usage.Command" xml:space="preserve"> + <value>DefaultHelpText.Usage.Command</value> + </data> + <data name="DefaultHelpText.Usage.Options" xml:space="preserve"> + <value>DefaultHelpText.Usage.Options</value> + </data> + <data name="DefaultHelpText.Usage.Title" xml:space="preserve"> + <value>DefaultHelpText.Usage.Title</value> + </data> + <data name="HelpBuilder.DefaultValueHint" xml:space="preserve"> + <value>default: {0}</value> + </data> + <data name="HelpBuilder.Option.IsRequired" xml:space="preserve"> + <value>(REQUIRED)</value> + </data> + <data name="HelpOption.Description" xml:space="preserve"> + <value>HelpOption.Description</value> + </data> + <data name="VersionOption.Description" xml:space="preserve"> + <value>VersionOption.Description</value> + </data> +</root> \ No newline at end of file diff --git a/src/System.CommandLine.Localization/LocalizedHelpBuilderFactory.cs b/src/System.CommandLine.Localization/LocalizedHelpBuilderFactory.cs new file mode 100644 index 0000000000..654d6ca41a --- /dev/null +++ b/src/System.CommandLine.Localization/LocalizedHelpBuilderFactory.cs @@ -0,0 +1,38 @@ +using System.CommandLine.Help; +using System.Reflection; +using Microsoft.Extensions.Localization; + +namespace System.CommandLine.Localization +{ + internal class LocalizedHelpBuilderFactory + { + private readonly IStringLocalizerFactory localizerFactory; + private readonly IConsole console; + private readonly int? columnGutter; + private readonly int? indentationSize; + private readonly int? maxWidth; + + public LocalizedHelpBuilderFactory( + IStringLocalizerFactory localizerFactory, IConsole console, + int? columnGutter = null, int? indentationSize = null, + int? maxWidth = null) : base() + { + this.localizerFactory = localizerFactory + ?? throw new ArgumentNullException(nameof(localizerFactory)); + this.console = console; + this.columnGutter = columnGutter; + this.indentationSize = indentationSize; + this.maxWidth = maxWidth; + } + + internal IHelpBuilder CreateHelpBuilder(Type? resourceSource = null) + { + if (resourceSource is null) + { + resourceSource = Assembly.GetEntryAssembly().EntryPoint.DeclaringType; + } + return new LocalizedHelpBuilder(localizerFactory, resourceSource, + console, columnGutter, indentationSize, maxWidth); + } + } +} diff --git a/src/System.CommandLine.Localization/System.CommandLine.Localization.csproj b/src/System.CommandLine.Localization/System.CommandLine.Localization.csproj new file mode 100644 index 0000000000..1a8fc6db02 --- /dev/null +++ b/src/System.CommandLine.Localization/System.CommandLine.Localization.csproj @@ -0,0 +1,28 @@ +<Project Sdk="Microsoft.NET.Sdk"> + + <PropertyGroup> + <IsPackable>true</IsPackable> + <PackageId>System.CommandLine.Localization</PackageId> + <TargetFramework>netstandard2.0</TargetFramework> + <LangVersion>8</LangVersion> + <Nullable>enable</Nullable> + <Description>This package provides localization support for System.CommandLine.</Description> + </PropertyGroup> + + <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> + <DebugType>portable</DebugType> + </PropertyGroup> + + <ItemGroup> + <PackageReference Include="Microsoft.Extensions.Localization" Version="3.1.6" /> + </ItemGroup> + + <ItemGroup> + <ProjectReference Include="..\System.CommandLine\System.CommandLine.csproj" /> + </ItemGroup> + + <ItemGroup> + <Compile Include="..\System.Diagnostics.CodeAnalysis.cs" Link="System.Diagnostics.CodeAnalysis.cs" /> + </ItemGroup> + +</Project> diff --git a/src/System.CommandLine.Localization/xlf/LocalizedHelpBuilder.de.xlf b/src/System.CommandLine.Localization/xlf/LocalizedHelpBuilder.de.xlf new file mode 100644 index 0000000000..109efff244 --- /dev/null +++ b/src/System.CommandLine.Localization/xlf/LocalizedHelpBuilder.de.xlf @@ -0,0 +1,72 @@ +<?xml version="1.0" encoding="utf-8"?> +<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" version="1.2" xsi:schemaLocation="urn:oasis:names:tc:xliff:document:1.2 xliff-core-1.2-transitional.xsd"> + <file datatype="xml" source-language="en" target-language="de" original="../LocalizedHelpBuilder.resx"> + <body> + <trans-unit id="DefaultHelpText.AdditionalArguments.Description"> + <source>DefaultHelpText.AdditionalArguments.Description</source> + <target state="new">An die Anwendung übergebende Argumente.</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.AdditionalArguments.Title"> + <source>DefaultHelpText.AdditionalArguments.Title</source> + <target state="new">Zusätzliche Argumente:</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Arguments.Title"> + <source>DefaultHelpText.Arguments.Title</source> + <target state="new">Argumente:</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Commands.Title"> + <source>DefaultHelpText.Commands.Title</source> + <target state="new">Befehle:</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Options.Title"> + <source>DefaultHelpText.Options.Title</source> + <target state="new">Optionen:</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Usage.AdditionalArguments"> + <source>DefaultHelpText.Usage.AdditionalArguments</source> + <target state="new">[[--] <zusätzliche argumente>...]]</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Usage.Command"> + <source>DefaultHelpText.Usage.Command</source> + <target state="new">[befehl]</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Usage.Options"> + <source>DefaultHelpText.Usage.Options</source> + <target state="new">[option]</target> + <note /> + </trans-unit> + <trans-unit id="DefaultHelpText.Usage.Title"> + <source>DefaultHelpText.Usage.Title</source> + <target state="new">Befehlszeile:</target> + <note /> + </trans-unit> + <trans-unit id="HelpBuilder.DefaultValueHint"> + <source>default: {0}</source> + <target state="new">standard: {0}</target> + <note /> + </trans-unit> + <trans-unit id="HelpBuilder.Option.IsRequired"> + <source>(REQUIRED)</source> + <target state="new">(ERFORDERLICH)</target> + <note /> + </trans-unit> + <trans-unit id="HelpOption.Description"> + <source>HelpOption.Description</source> + <target state="new">Hilfe und Gebrauchsanweisung anzeigen</target> + <note /> + </trans-unit> + <trans-unit id="VersionOption.Description"> + <source>VersionOption.Description</source> + <target state="new">Versionsinformation anzeigen</target> + <note /> + </trans-unit> + </body> + </file> +</xliff> \ No newline at end of file diff --git a/src/System.CommandLine/Help/HelpBuilder.cs b/src/System.CommandLine/Help/HelpBuilder.cs index 07cab25b6a..94bdaad15b 100644 --- a/src/System.CommandLine/Help/HelpBuilder.cs +++ b/src/System.CommandLine/Help/HelpBuilder.cs @@ -28,6 +28,25 @@ public class HelpBuilder : IHelpBuilder public int MaxWidth { get; } + protected string AdditionalArgumentsTitle { get; set; } = + AdditionalArguments.Title; + protected string AdditionalArgumentsDescription { get; set; } = + AdditionalArguments.Description; + protected string ArgumentsTitle { get; set; } = + Arguments.Title; + protected string CommandsTitle { get; set; } = + Commands.Title; + protected string OptionsTitle { get; set; } = + Options.Title; + protected string UsageAdditionalArgumentsText { get; set; } = + Usage.AdditionalArguments; + protected string UsageCommandText { get; set; } = + Usage.Command; + protected string UsageOptionsText { get; set; } = + Usage.Options; + protected string UsageTitle { get; set; } = + Usage.Title; + /// <summary> /// Brokers the generation and output of help text of <see cref="Symbol"/> /// and the <see cref="IConsole"/> @@ -577,7 +596,7 @@ protected virtual void AddUsage(ICommand command) if (hasOptionHelp) { - usage.Add(Usage.Options); + usage.Add(UsageOptionsText); } usage.Add(FormatArgumentUsage(command.Arguments.ToArray())); @@ -588,15 +607,15 @@ protected virtual void AddUsage(ICommand command) if (hasCommandHelp) { - usage.Add(Usage.Command); + usage.Add(UsageCommandText); } if (!command.TreatUnmatchedTokensAsErrors) { - usage.Add(Usage.AdditionalArguments); + usage.Add(UsageAdditionalArgumentsText); } - HelpSection.WriteHeading(this, Usage.Title, string.Join(" ", usage.Where(u => !string.IsNullOrWhiteSpace(u)))); + HelpSection.WriteHeading(this, UsageTitle, string.Join(" ", usage.Where(u => !string.IsNullOrWhiteSpace(u)))); } private string FormatArgumentUsage(IReadOnlyCollection<IArgument> arguments) @@ -674,7 +693,7 @@ protected virtual void AddArguments(ICommand command) HelpSection.WriteItems( this, - Arguments.Title, + ArgumentsTitle, commands.SelectMany(GetArgumentHelpItems).Distinct().ToArray()); } @@ -693,7 +712,7 @@ protected virtual void AddOptions(ICommand command) HelpSection.WriteItems( this, - Options.Title, + OptionsTitle, options.SelectMany(GetOptionHelpItems).Distinct().ToArray()); } @@ -711,7 +730,7 @@ protected virtual void AddSubcommands(ICommand command) .ToArray(); HelpSection.WriteItems(this, - Commands.Title, + CommandsTitle, subcommands.SelectMany(GetOptionHelpItems).ToArray()); } @@ -722,7 +741,9 @@ protected virtual void AddAdditionalArguments(ICommand command) return; } - HelpSection.WriteHeading(this, AdditionalArguments.Title, AdditionalArguments.Description); + HelpSection.WriteHeading(this, + AdditionalArgumentsTitle, + AdditionalArgumentsDescription); } private bool ShouldDisplayArgumentHelp(ICommand? command) @@ -861,12 +882,12 @@ private static void AddInvocation(HelpBuilder builder, IReadOnlyCollection<HelpI } } - internal bool ShouldShowHelp(ISymbol symbol) + protected bool ShouldShowHelp(ISymbol symbol) { return !symbol.IsHidden; } - internal bool ShouldShowDefaultValueHint(IArgument argument) + protected bool ShouldShowDefaultValueHint(IArgument argument) { return argument.HasDefaultValue && ShouldShowHelp(argument); }