From 81c4f28f5bd41178dd7de68df629374ed60d9586 Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 28 Sep 2026 18:00:47 +0200 Subject: [PATCH 1/2] feat: count, and on request import, a decimal written with the other separator where it can be read no other way --- .../Analysis/ColumnProfiler.cs | 15 ++- .../Analysis/CultureParseCounts.cs | 8 ++ .../Analysis/NumberReading.cs | 84 ++++++++++++++ .../Extraction/ExtractionCounters.cs | 3 + .../Extraction/ExtractionRun.cs | 18 ++- .../Extraction/ExtractionSummary.cs | 6 + .../Import/MappingPrecheck.cs | 8 +- src/TriasDev.Tabular/Mapping/ColumnBinding.cs | 13 +++ src/TriasDev.Tabular/Mapping/ValueReading.cs | 39 +++++++ src/TriasDev.Tabular/PublicAPI.Unshipped.txt | 6 + .../Import/OtherDecimalSeparatorTests.cs | 104 ++++++++++++++++++ 11 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 tests/TriasDev.Tabular.Tests/Import/OtherDecimalSeparatorTests.cs diff --git a/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs b/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs index 5251cb6..dce47a1 100644 --- a/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs +++ b/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs @@ -387,6 +387,7 @@ private sealed class CultureAccumulator(string name, int outlierLimit) private int _integer; private int _decimal; + private int _otherSeparator; private int _date; public int NumericCount => _integer + _decimal; @@ -495,8 +496,17 @@ public void AcceptText(string text, int rowNumber, NumberRead number, bool could Widen(number.Value); break; - case NumberKind.None when _numericOutliers.Count < outlierLimit: - _numericOutliers.Add(new ValueLocation { RowNumber = rowNumber, RawValue = text }); + case NumberKind.None: + if (NumberReading.IsOtherSeparatorDecimal(text, _culture.NumberFormat)) + { + _otherSeparator++; + } + + if (_numericOutliers.Count < outlierLimit) + { + _numericOutliers.Add(new ValueLocation { RowNumber = rowNumber, RawValue = text }); + } + break; } @@ -573,6 +583,7 @@ public CultureParseCounts ToCounts() => Culture = name, Integer = _integer, Decimal = _decimal, + OtherSeparatorDecimals = _otherSeparator, Date = _date, NumericOutliers = [.. _numericOutliers], DateOutliers = [.. _dateOutliers], diff --git a/src/TriasDev.Tabular/Analysis/CultureParseCounts.cs b/src/TriasDev.Tabular/Analysis/CultureParseCounts.cs index a2ed7ec..752e023 100644 --- a/src/TriasDev.Tabular/Analysis/CultureParseCounts.cs +++ b/src/TriasDev.Tabular/Analysis/CultureParseCounts.cs @@ -21,6 +21,14 @@ public sealed record CultureParseCounts /// Non-empty values that read as a date. public required int Date { get; init; } + /// + /// Non-empty values that did not read as a number here only because they write the decimal + /// separator the other way, and can be read no other way: 34.020367 under a German reading. + /// They are also among the numeric outliers; a binding may accept them with + /// . + /// + public int OtherSeparatorDecimals { get; init; } + /// Values that did not read as a number, up to the configured limit. public required IReadOnlyList NumericOutliers { get; init => field = Equatable.List(value); } diff --git a/src/TriasDev.Tabular/Analysis/NumberReading.cs b/src/TriasDev.Tabular/Analysis/NumberReading.cs index 1c5e2ab..996a3ec 100644 --- a/src/TriasDev.Tabular/Analysis/NumberReading.cs +++ b/src/TriasDev.Tabular/Analysis/NumberReading.cs @@ -19,6 +19,90 @@ internal static class NumberReading /// public const NumberStyles DecimalStyles = NumberStyles.Number | NumberStyles.AllowExponent; + /// + /// Reads a value that writes the decimal separator the other way from the culture, where that is + /// the only way it can be read: digits, one . or , — whichever the culture does not + /// use for decimals — and digits after it that are not exactly three, which would make it a group. + /// + /// + /// 34.020367 among German decimals cannot be a German grouped number, so its only sensible + /// reading is 34.020367 (#47). 1.234 could be either and is left alone; so is anything + /// holding a second separator, a space or an exponent. + /// + public static bool TryReadOtherSeparator(ReadOnlySpan text, NumberFormatInfo format, out decimal value) + { + value = 0; + + if (!IsOtherSeparatorDecimal(text, format, out char other)) + { + return false; + } + + ReadOnlySpan trimmed = text.Trim(); + Span invariant = stackalloc char[trimmed.Length]; + trimmed.CopyTo(invariant); + invariant.Replace(other, '.'); + + // Never false for a value the shape admits: at most 28 digits, one point, one sign. + return decimal.TryParse(invariant, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out value); + } + + /// + /// Whether the value has the shape reads, found without + /// allocating or parsing: the profile asks it of every value that failed as a number, in every + /// culture, and only counts. + /// + public static bool IsOtherSeparatorDecimal(ReadOnlySpan text, NumberFormatInfo format) => + IsOtherSeparatorDecimal(text, format, out _); + + private static bool IsOtherSeparatorDecimal(ReadOnlySpan text, NumberFormatInfo format, out char other) + { + other = format.NumberDecimalSeparator switch + { + "," => '.', + "." => ',', + _ => '\0', + }; + + ReadOnlySpan body = text.Trim(); + + if (other == '\0' || body.Length is 0 or > 30) + { + return false; + } + + if (body[0] is '-' or '+') + { + body = body[1..]; + } + + // One pass that gives up at the first character that is neither a digit nor the separator: + // the profile asks this of every text value that failed as a number, so a street name must + // cost one character, not a scan. + int at = -1; + + for (int i = 0; i < body.Length; i++) + { + char c = body[i]; + + if (char.IsAsciiDigit(c)) + { + continue; + } + + if (c != other || at >= 0) + { + return false; + } + + at = i; + } + + // Digits on both sides, the fraction not exactly three digits long — that would be a group — + // and no more than decimal holds. + return at > 0 && at < body.Length - 1 && body.Length - at - 1 != 3 && body.Length - 1 <= 28; + } + /// /// Whether every group separator in the value is followed by exactly three digits. /// diff --git a/src/TriasDev.Tabular/Extraction/ExtractionCounters.cs b/src/TriasDev.Tabular/Extraction/ExtractionCounters.cs index 65cec07..fc580ba 100644 --- a/src/TriasDev.Tabular/Extraction/ExtractionCounters.cs +++ b/src/TriasDev.Tabular/Extraction/ExtractionCounters.cs @@ -17,6 +17,8 @@ internal sealed class ExtractionCounters public bool StoppedEarly { get; set; } + public int OtherSeparatorDecimals { get; set; } + public ExtractionSummary Snapshot() => new() { RowsRead = RowsRead, @@ -26,5 +28,6 @@ internal sealed class ExtractionCounters RowsFailed = RowsFailed, ErrorCount = ErrorCount, StoppedEarly = StoppedEarly, + OtherSeparatorDecimals = OtherSeparatorDecimals, }; } diff --git a/src/TriasDev.Tabular/Extraction/ExtractionRun.cs b/src/TriasDev.Tabular/Extraction/ExtractionRun.cs index 66d5769..24a69d9 100644 --- a/src/TriasDev.Tabular/Extraction/ExtractionRun.cs +++ b/src/TriasDev.Tabular/Extraction/ExtractionRun.cs @@ -357,7 +357,7 @@ private void Convert(ReadOnlySpan row) continue; } - if (!ValueReading.TryRead(cell, text, field.Type, _culture, out MappedValue value)) + if (!TryReadValue(binding, cell, text, field, out MappedValue value)) { Fail(binding, ErrorCodes.Value.TypeMismatch, text); continue; @@ -379,6 +379,22 @@ private void Convert(ReadOnlySpan row) CheckRequiredGroups(); } + /// Reads a cell as its field's type, counting a decimal read with the other separator. + private bool TryReadValue(ColumnBinding binding, in RawCell cell, string text, ImportField field, out MappedValue value) + { + if (!ValueReading.TryRead(cell, text, field.Type, _culture, binding.AcceptOtherDecimalSeparator, out value, out bool otherSeparator)) + { + return false; + } + + if (otherSeparator) + { + _counters.OtherSeparatorDecimals++; + } + + return true; + } + /// /// Fails a row that carries none of a required group's languages. /// diff --git a/src/TriasDev.Tabular/Extraction/ExtractionSummary.cs b/src/TriasDev.Tabular/Extraction/ExtractionSummary.cs index 994a696..1b80e30 100644 --- a/src/TriasDev.Tabular/Extraction/ExtractionSummary.cs +++ b/src/TriasDev.Tabular/Extraction/ExtractionSummary.cs @@ -50,4 +50,10 @@ public sealed record ExtractionSummary /// file, so "no further errors" would be a claim nobody checked. /// public bool StoppedEarly { get; init; } + + /// + /// Values a decimal field read with the decimal separator written the other way, because its + /// binding accepts them (). + /// + public int OtherSeparatorDecimals { get; init; } } diff --git a/src/TriasDev.Tabular/Import/MappingPrecheck.cs b/src/TriasDev.Tabular/Import/MappingPrecheck.cs index 3cd5d6f..214bcb7 100644 --- a/src/TriasDev.Tabular/Import/MappingPrecheck.cs +++ b/src/TriasDev.Tabular/Import/MappingPrecheck.cs @@ -315,7 +315,7 @@ void Add(string code, PrecheckSeverity severity, Evidence evidence) => CheckUnique(field, binding, facts, sheet, plan, Add); CheckHeader(binding, facts, Add); CheckRules(field, binding, facts, culture, Add); - CheckType(field, facts, culture, Add); + CheckType(field, binding, facts, culture, Add); } /// Whether a required field's column can supply a value for every row. @@ -540,7 +540,7 @@ .. facts.DistinctValues .Where(v => !nothing.Contains(v)) .Select(v => { - bool ok = ValueReading.TryRead(Rebuild(v, declared), v, field.Type, reading, out MappedValue value); + bool ok = ValueReading.TryRead(Rebuild(v, declared), v, field.Type, reading, binding.AcceptOtherDecimalSeparator, out MappedValue value, out _); return (v, value, ok); }), ]; @@ -733,6 +733,7 @@ private static bool CannotBeSatisfiedByAnyRow(ImportField field, ColumnBinding b private static void CheckType( ImportField field, + ColumnBinding binding, ColumnFacts facts, string? culture, AddFinding add) @@ -771,7 +772,8 @@ private static void CheckType( int readable = field.Type switch { ColumnType.Integer => counts.Integer, - ColumnType.Decimal => counts.Integer + counts.Decimal, + // As the import will run it: with the values written the other way, when the binding takes them. + ColumnType.Decimal => counts.Integer + counts.Decimal + (binding.AcceptOtherDecimalSeparator ? counts.OtherSeparatorDecimals : 0), ColumnType.Date => counts.Date, ColumnType.Boolean => facts.BooleanCount, _ => facts.NonEmptyCount, diff --git a/src/TriasDev.Tabular/Mapping/ColumnBinding.cs b/src/TriasDev.Tabular/Mapping/ColumnBinding.cs index a94de34..7ca051e 100644 --- a/src/TriasDev.Tabular/Mapping/ColumnBinding.cs +++ b/src/TriasDev.Tabular/Mapping/ColumnBinding.cs @@ -22,4 +22,17 @@ public sealed record ColumnBinding /// Values to read as absent, such as a placeholder a spreadsheet uses for "unknown". public IReadOnlyList TreatAsEmpty { get; init => field = Equatable.List(value); } = Equatable.Empty(); + + /// + /// Whether a decimal field reads a value that writes the decimal separator the other way from + /// the plan's culture, where that is the only way it can be read — 34.020367 under de-DE. + /// + /// + /// Off by default: the reading is unambiguous, but it is still a guess about a file mixing two + /// conventions, and a person decides whether to make it. The profile counts such values per + /// culture (), the precheck judges the plan + /// with this setting, and a run counts the values it read this way + /// (), so the leniency is never silent. + /// + public bool AcceptOtherDecimalSeparator { get; init; } } diff --git a/src/TriasDev.Tabular/Mapping/ValueReading.cs b/src/TriasDev.Tabular/Mapping/ValueReading.cs index 7646f19..143b786 100644 --- a/src/TriasDev.Tabular/Mapping/ValueReading.cs +++ b/src/TriasDev.Tabular/Mapping/ValueReading.cs @@ -31,6 +31,45 @@ internal static class ValueReading /// for it to come out wrong. /// public static bool TryRead( + in RawCell cell, + string text, + ColumnType type, + CultureInfo culture, + out MappedValue value) => + TryRead(cell, text, type, culture, acceptOtherSeparator: false, out value, out _); + + /// + /// Reads a value as the target field's type, and a decimal written with the other separator too + /// when the binding accepts it; says whether it was read that way. + /// + public static bool TryRead( + in RawCell cell, + string text, + ColumnType type, + CultureInfo culture, + bool acceptOtherSeparator, + out MappedValue value, + out bool readByOtherSeparator) + { + readByOtherSeparator = false; + + if (TryReadAs(cell, text, type, culture, out value)) + { + return true; + } + + if (acceptOtherSeparator && type == ColumnType.Decimal && cell.Kind != RawCellKind.Number + && NumberReading.TryReadOtherSeparator(text, culture.NumberFormat, out decimal other)) + { + value = MappedValue.FromDecimal(other); + readByOtherSeparator = true; + return true; + } + + return false; + } + + private static bool TryReadAs( in RawCell cell, string text, ColumnType type, diff --git a/src/TriasDev.Tabular/PublicAPI.Unshipped.txt b/src/TriasDev.Tabular/PublicAPI.Unshipped.txt index f9372d6..7ca5d5b 100644 --- a/src/TriasDev.Tabular/PublicAPI.Unshipped.txt +++ b/src/TriasDev.Tabular/PublicAPI.Unshipped.txt @@ -7,3 +7,9 @@ TriasDev.Tabular.SheetVisibility TriasDev.Tabular.SheetVisibility.Hidden = 1 -> TriasDev.Tabular.SheetVisibility TriasDev.Tabular.SheetVisibility.VeryHidden = 2 -> TriasDev.Tabular.SheetVisibility TriasDev.Tabular.SheetVisibility.Visible = 0 -> TriasDev.Tabular.SheetVisibility +TriasDev.Tabular.ColumnBinding.AcceptOtherDecimalSeparator.get -> bool +TriasDev.Tabular.ColumnBinding.AcceptOtherDecimalSeparator.init -> void +TriasDev.Tabular.CultureParseCounts.OtherSeparatorDecimals.get -> int +TriasDev.Tabular.CultureParseCounts.OtherSeparatorDecimals.init -> void +TriasDev.Tabular.ExtractionSummary.OtherSeparatorDecimals.get -> int +TriasDev.Tabular.ExtractionSummary.OtherSeparatorDecimals.init -> void diff --git a/tests/TriasDev.Tabular.Tests/Import/OtherDecimalSeparatorTests.cs b/tests/TriasDev.Tabular.Tests/Import/OtherDecimalSeparatorTests.cs new file mode 100644 index 0000000..ebbae92 --- /dev/null +++ b/tests/TriasDev.Tabular.Tests/Import/OtherDecimalSeparatorTests.cs @@ -0,0 +1,104 @@ +using System.Text; + +using TriasDev.Tabular.Csv; + +using Xunit; + +namespace TriasDev.Tabular.Tests.Import; + +/// +/// A value that writes the decimal separator the other way, and can only be read that way, is counted +/// by the profile and — only when a binding asks for it — imported under its own separator (#47). +/// +/// +/// Hand-assembled files mix sources: one 34.020367 among German decimals cannot be a German +/// grouped number, a group having three digits. Off by default, as everything here proposes and a +/// person disposes; counted in the profile and the run, so the leniency is never silent. +/// +public sealed class OtherDecimalSeparatorTests +{ + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static readonly string[] Latitudes = ["48,183604", "48,860794", "45,85339", "35,332261", "34.020367"]; + + private static readonly DecimalImportField Lat = ImportField.Decimal("lat"); + + private static CsvCursor Csv(IEnumerable values) => + new(new MemoryStream(Encoding.UTF8.GetBytes("id;lat\n" + string.Concat(values.Select((v, i) => $"{i + 1};{v}\n")))), "t.csv"); + + private static MappingPlan Plan(bool accept) => new() + { + Culture = "de-DE", + Bindings = [new ColumnBinding { ColumnIndex = 1, FieldName = "lat", Header = "lat", AcceptOtherDecimalSeparator = accept }], + }; + + private static ImportSchema Schema => new() { Fields = [Lat] }; + + private static FileProfile Profile(IEnumerable values) + { + using CsvCursor cursor = Csv(values); + return TabularAnalyzer.Analyze(cursor, new AnalysisOptions { Cultures = ["de-DE", "en-US"] }, cancellationToken: Token); + } + + [Fact] + public void CountsAValueThatCanOnlyBeReadUnderTheOtherSeparator() + { + ColumnFacts facts = Profile(Latitudes).Sheets[0].Columns[1].Facts; + CultureParseCounts german = facts.ParseCounts.Single(c => c.Culture == "de-DE"); + CultureParseCounts english = facts.ParseCounts.Single(c => c.Culture == "en-US"); + + Assert.Equal(4, german.Decimal); + Assert.Equal(1, german.OtherSeparatorDecimals); + Assert.Equal("34.020367", Assert.Single(german.NumericOutliers).RawValue); + + // Seen from the other side, the four German values are the ones written the other way. + Assert.Equal(4, english.OtherSeparatorDecimals); + } + + [Theory] + [InlineData("1.2.3")] + [InlineData("1.5e3")] + [InlineData("1.234,5")] + [InlineData("12. 5")] + [InlineData(".")] + public void DoesNotCountAValueThatIsNotUnambiguous(string value) + { + CultureParseCounts german = Profile(["1,5", value]).Sheets[0].Columns[1].Facts.ParseCounts.Single(c => c.Culture == "de-DE"); + + Assert.Equal(0, german.OtherSeparatorDecimals); + } + + [Fact] + public void ImportsSuchAValueWhenTheBindingAcceptsIt() + { + using CsvCursor cursor = Csv(Latitudes); + using ImportRun run = TabularImporter.Import(cursor, Plan(accept: true), Schema, row => row[Lat], cancellationToken: Token); + + ImportResult result = run.ReadAll(cancellationToken: Token); + + Assert.Empty(result.Errors); + Assert.Equal([48.183604m, 48.860794m, 45.85339m, 35.332261m, 34.020367m], result.Items); + Assert.Equal(1, result.Summary.OtherSeparatorDecimals); + } + + [Fact] + public void RefusesSuchAValueByDefault() + { + using CsvCursor cursor = Csv(Latitudes); + using ImportRun run = TabularImporter.Import(cursor, Plan(accept: false), Schema, row => row[Lat], cancellationToken: Token); + + ImportResult result = run.ReadAll(cancellationToken: Token); + + Assert.Equal(ErrorCodes.Value.TypeMismatch, Assert.Single(result.Errors).Code); + Assert.Equal(0, result.Summary.OtherSeparatorDecimals); + } + + [Fact] + public void JudgesThePlanAsTheImportWillRunIt() + { + FileProfile profile = Profile(Latitudes); + + Assert.DoesNotContain(MappingPrecheck.Check(Plan(accept: true), Schema, profile).Findings, f => f.Code == ErrorCodes.Value.TypeMismatch); + Assert.Contains(MappingPrecheck.Check(Plan(accept: false), Schema, profile).Findings, f => f.Code == ErrorCodes.Value.TypeMismatch); + } +} From f39bfaac33970e6a7879e0de8f57ec6144d5c98c Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 28 Sep 2026 18:09:31 +0200 Subject: [PATCH 2/2] perf: the other-separator check runs only on values that could be numbers, with the separator looked up once --- docs/importing.md | 19 +++++++++++++++ .../Analysis/ColumnProfiler.cs | 11 ++++++--- .../Analysis/NumberReading.cs | 24 +++++++++---------- 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/docs/importing.md b/docs/importing.md index 199e68a..c7686f4 100644 --- a/docs/importing.md +++ b/docs/importing.md @@ -161,6 +161,25 @@ defect in the caller and reads as one instead of arriving as an empty column. **A row is either values or errors, never both.** Half a row invites half an entity, which is how silent corruption starts. +## A column that mixes decimal separators + +A hand-assembled file can hold `48,183604` in most rows and `34.020367` in one: a block pasted from +another tool. Under `de-DE` the second is no number, and since a German group has exactly three digits +it cannot be one either — its only reading is 34.020367. The profile counts such values per culture, +as `CultureParseCounts.OtherSeparatorDecimals`, so a screen can say "1 value uses a decimal point" +rather than show an unexplained outlier. + +Importing them is the caller's decision, per binding: + +```csharp +new ColumnBinding { ColumnIndex = 1, FieldName = "lat", Header = "lat", AcceptOtherDecimalSeparator = true } +``` + +A decimal field then reads a value written with the other separator, where it can be read no other +way: digits, that one separator, and not exactly three digits after it. `1.234` stays what the +culture makes of it. The precheck judges the plan with the setting, and the run counts the values it +read this way in `ExtractionSummary.OtherSeparatorDecimals`. It is off by default. + ## A field the file says in several languages A catalogue carries `Title#en` beside `Title#de` — two columns saying one thing. Declared once: diff --git a/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs b/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs index dce47a1..4c473c2 100644 --- a/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs +++ b/src/TriasDev.Tabular/Analysis/ColumnProfiler.cs @@ -170,7 +170,7 @@ public void Accept(in RawCell cell, int rowNumber) int twin = _numberTwin[i]; _numberReads[i] = twin == i ? _cultures[i].ReadNumber(text, couldBeNumeric) : _numberReads[twin]; - _cultures[i].AcceptText(text, rowNumber, _numberReads[i], couldBeDate); + _cultures[i].AcceptText(text, rowNumber, _numberReads[i], couldBeNumeric, couldBeDate); } } @@ -385,6 +385,11 @@ private sealed class CultureAccumulator(string name, int outlierLimit) private readonly List _numericOutliers = []; private readonly List _dateOutliers = []; + /// The decimal separator this culture does not use, looked up once rather than per value. + private char OtherSeparator { get; } = NumberReading.OtherSeparatorOf(name.Length == 0 + ? CultureInfo.InvariantCulture.NumberFormat + : CultureInfo.GetCultureInfo(name).NumberFormat); + private int _integer; private int _decimal; private int _otherSeparator; @@ -482,7 +487,7 @@ public NumberRead ReadNumber(string text, bool couldBeNumeric) /// Where it stands, for an outlier. /// What made of it, here or under a twin culture. /// Whether the value has the shape of a date, asked once by the caller. - public void AcceptText(string text, int rowNumber, NumberRead number, bool couldBeDate) + public void AcceptText(string text, int rowNumber, NumberRead number, bool couldBeNumeric, bool couldBeDate) { switch (number.Kind) { @@ -497,7 +502,7 @@ public void AcceptText(string text, int rowNumber, NumberRead number, bool could break; case NumberKind.None: - if (NumberReading.IsOtherSeparatorDecimal(text, _culture.NumberFormat)) + if (couldBeNumeric && NumberReading.IsOtherSeparatorDecimal(text, OtherSeparator)) { _otherSeparator++; } diff --git a/src/TriasDev.Tabular/Analysis/NumberReading.cs b/src/TriasDev.Tabular/Analysis/NumberReading.cs index 996a3ec..f35f222 100644 --- a/src/TriasDev.Tabular/Analysis/NumberReading.cs +++ b/src/TriasDev.Tabular/Analysis/NumberReading.cs @@ -33,7 +33,9 @@ public static bool TryReadOtherSeparator(ReadOnlySpan text, NumberFormatIn { value = 0; - if (!IsOtherSeparatorDecimal(text, format, out char other)) + char other = OtherSeparatorOf(format); + + if (!IsOtherSeparatorDecimal(text, other)) { return false; } @@ -47,23 +49,21 @@ public static bool TryReadOtherSeparator(ReadOnlySpan text, NumberFormatIn return decimal.TryParse(invariant, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out value); } + /// The decimal separator a culture does not use — . for , and back — or \0. + public static char OtherSeparatorOf(NumberFormatInfo format) => format.NumberDecimalSeparator switch + { + "," => '.', + "." => ',', + _ => '\0', + }; + /// /// Whether the value has the shape reads, found without /// allocating or parsing: the profile asks it of every value that failed as a number, in every /// culture, and only counts. /// - public static bool IsOtherSeparatorDecimal(ReadOnlySpan text, NumberFormatInfo format) => - IsOtherSeparatorDecimal(text, format, out _); - - private static bool IsOtherSeparatorDecimal(ReadOnlySpan text, NumberFormatInfo format, out char other) + public static bool IsOtherSeparatorDecimal(ReadOnlySpan text, char other) { - other = format.NumberDecimalSeparator switch - { - "," => '.', - "." => ',', - _ => '\0', - }; - ReadOnlySpan body = text.Trim(); if (other == '\0' || body.Length is 0 or > 30)