fix(unparser): format values with parser ParsingCulture for round-trip

FormatCommandLine wrote option and value arguments using the ambient
thread culture via value.ToString(), while the parser reads them back
with ParserSettings.ParsingCulture (invariant by default). On threads
whose culture uses a comma decimal separator (for example de-DE) a
double 1.5 was unparsed as "1,5" and reparsed as 15, and a sequence
[1.5, 2.5] became "1,5,2,5" and reparsed as four elements [1,5,2,5],
so the format/parse round-trip was broken.

Format IFormattable values (numeric and date/time types) with the
parser ParsingCulture instead of the current thread culture, for both
scalar and separator-joined sequence values. Other types keep their
default string representation, so behaviour on invariant/en-US is
unchanged.

Add round-trip tests under de-DE for a scalar double and a
comma-separated double sequence.
This commit is contained in:
dualfroz committed 2026-09-02 12:13:31 +02:00
1 parent 6340b0ba56
commit 91e0d866bc
3 files changed
+104 -9

No files matched your search

+30 -9
View File
@@ -3,6 +3,7 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using CommandLine.Core;
@@ -132,6 +133,11 @@ namespace CommandLine
configuration(settings);
settings.Consumed = true;
// Values are formatted using the parser's parsing culture (invariant by
// default) so that the produced command line round-trips through the same
// parser even when the current thread uses a different culture.
var parsingCulture = parser.Settings.ParsingCulture;
var type = options.GetType();
var builder = new StringBuilder();
@@ -188,14 +194,14 @@ namespace CommandLine
optSpecs.ForEach(
opt =>
builder
.Append(FormatOption((OptionSpecification)opt.Specification, opt.Value, settings))
.Append(FormatOption((OptionSpecification)opt.Specification, opt.Value, settings, parsingCulture))
.Append(' ')
);
builder.AppendWhen(valSpecs.Any() && parser.Settings.EnableDashDash, "-- ");
valSpecs.ForEach(
val => builder.Append(FormatValue(val.Specification, val.Value)).Append(' '));
val => builder.Append(FormatValue(val.Specification, val.Value, parsingCulture)).Append(' '));
return builder
.ToString().TrimEnd(' ');
@@ -213,18 +219,18 @@ namespace CommandLine
{
return FormatCommandLine<T>(parser, options, configuration).SplitArgs();
}
private static string FormatValue(Specification spec, object value)
private static string FormatValue(Specification spec, object value, CultureInfo parsingCulture)
{
var builder = new StringBuilder();
switch (spec.TargetType)
{
case TargetType.Scalar:
builder.Append(FormatWithQuotesIfString(value));
builder.Append(FormatWithQuotesIfString(value, parsingCulture));
break;
case TargetType.Sequence:
var sep = spec.SeperatorOrSpace();
Func<object, object> format = v
=> sep == ' ' ? FormatWithQuotesIfString(v) : v;
=> sep == ' ' ? FormatWithQuotesIfString(v, parsingCulture) : FormatCulturedValue(v, parsingCulture);
var e = ((IEnumerable)value).GetEnumerator();
while (e.MoveNext())
builder.Append(format(e.Current)).Append(sep);
@@ -234,9 +240,9 @@ namespace CommandLine
return builder.ToString();
}
private static object FormatWithQuotesIfString(object value)
private static object FormatWithQuotesIfString(object value, CultureInfo parsingCulture)
{
string s = value.ToString();
string s = FormatCulturedValue(value, parsingCulture);
if (!string.IsNullOrEmpty(s) && !s.Contains("\"") && s.Contains(" "))
return $"\"{s}\"";
@@ -248,17 +254,32 @@ namespace CommandLine
? "\"".JoinTo(doubQt(v), "\"") : v, value);
}
/// <summary>
/// Converts a value to its string representation using the parser's parsing
/// culture so that the unparsed command line round-trips through the parser
/// regardless of the current thread culture. Types implementing
/// <see cref="IFormattable"/> (such as numeric and date/time values) are
/// formatted with <paramref name="parsingCulture"/>; other types fall back to
/// their default string representation.
/// </summary>
private static string FormatCulturedValue(object value, CultureInfo parsingCulture)
{
return value is IFormattable formattable
? formattable.ToString(null, parsingCulture)
: value?.ToString();
}
private static char SeperatorOrSpace(this Specification spec)
{
return (spec as OptionSpecification).ToMaybe()
.MapValueOrDefault(o => o.Separator != '\0' ? o.Separator : ' ', ' ');
}
private static string FormatOption(OptionSpecification spec, object value, UnParserSettings settings)
private static string FormatOption(OptionSpecification spec, object value, UnParserSettings settings, CultureInfo parsingCulture)
{
return new StringBuilder()
.Append(spec.FormatName(value, settings))
.AppendWhen(spec.TargetType != TargetType.Switch, FormatValue(spec, value))
.AppendWhen(spec.TargetType != TargetType.Switch, FormatValue(spec, value, parsingCulture))
.ToString();
}