diff --git a/CHANGES.md b/CHANGES.md index 363388b1..9f9bfaff 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,7 +1,7 @@ Latest Changes ==== -What's new in v3.0.0-rc.1 +What's new in v3.0.0-rc.2 ==== Changes to release v2.7.x @@ -434,6 +434,7 @@ SmartFormat is the core package. It comes with the most frequently used extensio * `ValueTupleSource` ✔️ * `ReflectionSource` ✔️ * `DefaultSource` ✔️ + * `KeyValuePairSource` ✔️ 2) Formatter extensions: @@ -502,7 +503,64 @@ var formatter = new SmartFormatter() .InsertExtension(0, new MyCustomFormatter()); ``` -### 24. Miscellaneous +### 25. Introduced new `KeyValuePairSource` ([#244](https://github.com/axuno/SmartFormat/pull/244)) + +The `KeyValuePairSource` as a simple, cheap and performant way to create named placeholders. + +> Important: The type arguments for `KeyValuePair` *must* be ``. `KeyValuePair`s may be nested. + +Example: +```Csharp +Smart.Format("{placeholder}", new KeyValuePair("placeholder", "some value") +// Result: "some value" +``` + +`KeyValuePairSource` is included in `Smart.CreateDefaultFormatter()`. + + +### 26. Advanced features for `IsMatchFormatter` ([#245](https://github.com/axuno/SmartFormat/pull/244)) + +The `IsMatchFormatter` is a formatter with evaluation of regular expressions. + +**New:** The formatter can output matching group values of a `RegEx`. + +#### Example: + +We'll evalute this argument with `IsMatchFormatter`: +```Csharp +KeyValuePair arg = new("theValue", "Some123Content"); +``` + +##### a) Simple "match or no match" distinction: + +This behavior is unchanged. + +```Csharp +_ = Smart.Format("{theValue:ismatch(^.+123.+$):Okay - {}|No match content}", arg); +// Result: "Okay - Some123Content" + +_ = Smart.Format("{theValue:ismatch(^.+999.+$):Okay - {}|No match content}", arg); +// Result: "No match content" +``` + +##### b) Show the matches in the output: + +```Csharp +// List the matching RegEx group values +_ = Smart.Format("{theValue:ismatch(^.+\\(1\\)\\(2\\)\\(3\\).+$):Matches for '{}'\\: {m:list:| - }|No match}", arg); +// Result: "Matches for 'Some123Content': Some123Content - 1 - 2 - 3" + +// Show specific matching RegEx group values with their index in the list +_ = Smart.Format("{theValue:ismatch(^.+\\(1\\)\\(2\\)\\(3\\).+$):First 2 matches in '{}'\\: {m[1]} and {m[2]}|No match}", arg); +// Result: "First 2 matches in 'Some123Content': 1 and 2" +``` + +The placeholder `m` is for the collection of matching RegEx group values generated by `IsMatchFormatter`. The collection has at least one entry for a successful match. See more details in the Microsoft docs for the `GroupCollection` class. + +The name of the placeholder can be set with `IsMatchFormatter.PlaceholderNameForMatches`. "m" is the default. + + +### 27. Miscellaneous a) Cyhsarp.Text diff --git a/src/SmartFormat.Tests/Extensions/IsMatchFormatterTests.cs b/src/SmartFormat.Tests/Extensions/IsMatchFormatterTests.cs index 6d2407a9..81d633ba 100644 --- a/src/SmartFormat.Tests/Extensions/IsMatchFormatterTests.cs +++ b/src/SmartFormat.Tests/Extensions/IsMatchFormatterTests.cs @@ -13,76 +13,106 @@ namespace SmartFormat.Tests.Extensions [TestFixture] public class IsMatchFormatterTests { - private Dictionary _variable = new() { {"theKey", "Some123Content"}}; - private SmartFormatter _formatter; + private KeyValuePair _variable = new("theValue", "Some123Content"); - public IsMatchFormatterTests() + private static SmartFormatter GetFormatter() { - _formatter = Smart.CreateDefaultSmartFormat(new SmartSettings + var smart = Smart.CreateDefaultSmartFormat(new SmartSettings { Formatter = new FormatterSettings { ErrorAction = FormatErrorAction.ThrowError } }) - .AddExtensions(new IsMatchFormatter { RegexOptions = RegexOptions.CultureInvariant }); + .AddExtensions(new IsMatchFormatter()); + var mf = smart.GetFormatterExtension()!; + mf.RegexOptions = RegexOptions.CultureInvariant; + mf.PlaceholderNameForMatches = "m"; + + return smart; } - [TestCase("{theKey:ismatch(^.+123.+$):Okay - {}|No match content}", RegexOptions.None, "Okay - Some123Content")] - [TestCase("{theKey:ismatch(^.+123.+$):Fixed content if match|No match content}", RegexOptions.None, "Fixed content if match")] - [TestCase("{theKey:ismatch(^.+999.+$):{}|No match content}", RegexOptions.None, "No match content")] - [TestCase("{theKey:ismatch(^.+123.+$):|Only content with no match}", RegexOptions.None, "")] - [TestCase("{theKey:ismatch(^.+999.+$):|Only content with no match}", RegexOptions.None, "Only content with no match")] - [TestCase("{theKey:ismatch(^SOME123.+$):Okay - {}|No match content}", RegexOptions.IgnoreCase, "Okay - Some123Content")] - [TestCase("{theKey:ismatch(^SOME123.+$):Okay - {}|No match content}", RegexOptions.None, "No match content")] + [TestCase("{theValue:ismatch(^.+123.+$):Okay - {}|No match content}", RegexOptions.None, "Okay - Some123Content")] + [TestCase("{theValue:ismatch(^.+123.+$):Fixed content if match|No match content}", RegexOptions.None, "Fixed content if match")] + [TestCase("{theValue:ismatch(^.+999.+$):{}|No match content}", RegexOptions.None, "No match content")] + [TestCase("{theValue:ismatch(^.+123.+$):|Only content with no match}", RegexOptions.None, "")] + [TestCase("{theValue:ismatch(^.+999.+$):|Only content with no match}", RegexOptions.None, "Only content with no match")] + [TestCase("{theValue:ismatch(^SOME123.+$):Okay - {}|No match content}", RegexOptions.IgnoreCase, "Okay - Some123Content")] + [TestCase("{theValue:ismatch(^SOME123.+$):Okay - {}|No match content}", RegexOptions.None, "No match content")] public void Test_Formats_And_CaseSensitivity(string format, RegexOptions options, string expected) { - ((IsMatchFormatter) _formatter.FormatterExtensions.First(fex => - fex.GetType() == typeof(IsMatchFormatter))).RegexOptions = options; + var smart = GetFormatter(); + smart.GetFormatterExtension()!.RegexOptions = options; + var result = smart.Format(format, _variable); - Assert.AreEqual(expected, _formatter.Format(format, _variable)); + Assert.That(result, Is.EqualTo(expected)); } [Test] public void Less_Than_2_Format_Options_Should_Throw() { + var smart = GetFormatter(); // less than 2 format options should throw exception - Assert.Throws(() => - _formatter.Format("{theKey:ismatch(^.+123.+$):Dummy content}", _variable)); + Assert.Throws(code: () => + smart.Format("{theValue:ismatch(^.+123.+$):Dummy content}", _variable)); } - // The "{}" in the format will (as always) output the matching variable - [TestCase("{theKey:ismatch(^.+123.+$):|Has match for '{}'|\t|No match|}", "|Has match for 'Some123Content'|")] - [TestCase("{theKey:ismatch(^.+999.+$):|Has match for '{}'|\t|No match|}", "|No match|")] + // The "{}" in the format will output the input variable + [TestCase("{theValue:ismatch(^.+123.+$):|Has match for '{}'|\t|No match|}", "|Has match for 'Some123Content'|")] + [TestCase("{theValue:ismatch(^.+999.+$):|Has match for '{}'|\t|No match|}", "|No match|")] public void Test_With_Changed_SplitChar(string format, string expected) { - var variable = new Dictionary { {"theKey", "Some123Content"}}; - var smart = Smart.CreateDefaultSmartFormat(); + var variable = new Dictionary { {"theValue", "Some123Content"}}; + var smart = GetFormatter();; // Set SplitChar from | to TAB, so we can use | for the output string smart.GetFormatterExtension()!.SplitChar = '\t'; var result = smart.Format(format, variable); Assert.That(result, Is.EqualTo(expected)); } + [Test, Description("Output with RegEx matching group values")] + [TestCase("{theValue:ismatch(^.+\\(1\\)\\(2\\)\\(3\\).+$):Matches for '{}'\\: {m:list:| - }|No match}", "Matches for 'Some123Content': Some123Content - 1 - 2 - 3")] + [TestCase("{theValue:ismatch(^.+\\(9\\)\\(9\\)\\(9\\).+$):Matches for '{}'\\: {m:list:| - }|No match}", "No match")] + [TestCase("{theValue:ismatch(^.+\\(1\\)\\(2\\)\\(3\\).+$):First 2 matches in '{}'\\: {m[1]} and {m[2]}|No match}", "First 2 matches in 'Some123Content': 1 and 2")] + public void Match_And_List_Matches(string format, string expected) + { + var variable = new KeyValuePair ("theValue", "Some123Content"); + var smart = GetFormatter(); + var result = smart.Format(format, variable); + Assert.That(result, Is.EqualTo(expected)); + } + + [Test, Description("The name of the placeholder for RegEx matching group values can be changed")] + public void Change_Placeholder_Name_For_Matches() + { + var variable = new KeyValuePair ("theValue", "12345"); + var smart = GetFormatter(); + smart.GetFormatterExtension()!.PlaceholderNameForMatches = "match"; + + var format = "{theValue:ismatch(^\\(123\\)\\(4\\)\\(5\\)$):First match in '{}'\\: {match[1]}|No match}"; + + var result = smart.Format(format, variable); + Assert.That(result, Is.EqualTo("First match in '12345': 123")); + } + [Test] - public void Test_List() + public void Match_Nested_In_ListFormatter() { + var smart = GetFormatter(); var myList = new List {100, 200, 300}; - Assert.AreEqual("100.00, 200.00 and 'no match'", - _formatter.Format(CultureInfo.InvariantCulture, - "{0:list:{:ismatch(^100|200|999$):{:0.00}|'no match'}|, | and }", myList)); - - Assert.AreEqual("'match', 'match' and 'no match'", - _formatter.Format(CultureInfo.InvariantCulture, - "{0:list:{:ismatch(^100|200|999$):'match'|'no match'}|, | and }", myList)); + Assert.AreEqual("100.00, 200.00 and no match for '300'", + smart.Format(CultureInfo.InvariantCulture, + "{0:list:{:ismatch(^100|200|999$):{:0.00}|no match for '{}'}|, | and }", myList)); } - [TestCase("€ Euro", true)] - [TestCase("¥ Yen", true)] - [TestCase("none", false)] - public void Currency_Symbol(string currency, bool isMatch) + [TestCase("€ Euro", "Currency: €")] + [TestCase("¥ Yen", "Currency: ¥")] + [TestCase("none", "Unknown")] + public void Currency_Symbol(string currency, string expected) { + var smart = GetFormatter(); var variable = new { Currency = currency}; // If special characters like \{}: are escaped, they can be used in format options: - var result = _formatter.Format("{Currency:ismatch(\\\\p\\{Sc\\}):Currency: {}|Unknown}", variable); - if (isMatch) Assert.IsTrue(result.Contains("Currency"), "Result contains Currency"); - if (!isMatch) Assert.IsTrue(result.Contains("Unknown"), "Result contains Unknown"); + var regex = "\\p{Sc}"; + var escapedRegex = new string(EscapedLiteral.EscapeCharLiterals('\\', regex, 0, regex.Length, true).ToArray()); + var result = smart.Format("{Currency:ismatch(" + escapedRegex + "):Currency: {m[0]}|Unknown}", variable); + Assert.That(result, Is.EqualTo(expected)); } // Single-escaped: only for RegEx @@ -104,11 +134,11 @@ public void Currency_Symbol(string currency, bool isMatch) [TestCase("}", @"\}", @"\\\}")] public void Escaped_Option_And_RegEx_Chars(string search, string regExEscaped, string optionsEscaped) { + var smart = GetFormatter(); // To be escaped with backslash for PCRE RegEx: ".^$*+?()[]{}\|" - var regEx = new Regex(regExEscaped); Assert.IsTrue(regEx.Match(search).Success); - var result = _formatter.Format("{0:ismatch(" + optionsEscaped + "):found {}|}", search); + var result = smart.Format("{0:ismatch(" + optionsEscaped + "):found {}|}", search); Assert.That(result, Is.EqualTo("found " + search)); } @@ -124,13 +154,14 @@ public void Escaped_Option_And_RegEx_Chars(string search, string regExEscaped, s [TestCase(@"^.{5,}:,$", "1z:,", false)] public void Match_Special_Characters(string pattern, string input, bool shouldMatch) { + var smart = GetFormatter(); var regExOptions = RegexOptions.None; - ((IsMatchFormatter) _formatter.FormatterExtensions.First(fex => + ((IsMatchFormatter) smart.FormatterExtensions.First(fex => fex.GetType() == typeof(IsMatchFormatter))).RegexOptions = regExOptions; var regEx = new Regex(pattern, regExOptions); var optionsEscaped = new string(EscapedLiteral.EscapeCharLiterals('\\', pattern, 0, pattern.Length, true).ToArray()); - var result = _formatter.Format("{0:ismatch(" + optionsEscaped + "):found {}|}", input); + var result = smart.Format("{0:ismatch(" + optionsEscaped + "):found {}|}", input); Assert.That(regEx.Match(input).Success, Is.EqualTo(shouldMatch), "RegEx pattern match"); Assert.That(result, shouldMatch ? Is.EqualTo("found " + input) : Is.EqualTo(string.Empty), "IsMatchFormatter pattern match"); diff --git a/src/SmartFormat/Extensions/IsMatchFormatter.cs b/src/SmartFormat/Extensions/IsMatchFormatter.cs index 2678b4ec..c6274923 100644 --- a/src/SmartFormat/Extensions/IsMatchFormatter.cs +++ b/src/SmartFormat/Extensions/IsMatchFormatter.cs @@ -4,22 +4,30 @@ // using System; +using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; using SmartFormat.Core.Extensions; +using SmartFormat.Core.Parsing; namespace SmartFormat.Extensions { /// /// Formatter with evaluation of regular expressions. + /// The formatter can output matching group values. /// - /// + /// /// Syntax: - /// {value:ismatch(regex): format | default} + /// {value:ismatch(regex): format | default} + /// /// Or in context of a list: - /// {myList:list:{:ismatch(^regex$):{:format}|'no match'}|, | and } - /// - public class IsMatchFormatter : IFormatter + /// {myList:list:{:ismatch(^regex$):{:format}|'no match'}|, | and } + /// + /// Or with output of the first matching group value: + /// {value:ismatch(regex):First match in '{}'\\: {m[1]}|No match} + /// + /// + public class IsMatchFormatter : IFormatter, IInitializer { /// /// Obsolete. s only have one unique name. @@ -41,6 +49,10 @@ public class IsMatchFormatter : IFormatter /// public bool TryEvaluateFormat(IFormattingInfo formattingInfo) { + // Cannot deal with null + if (formattingInfo.CurrentValue is null) + return false; + var expression = formattingInfo.FormatterOptions; var formats = formattingInfo.Format?.Split(SplitChar); @@ -60,18 +72,84 @@ public bool TryEvaluateFormat(IFormattingInfo formattingInfo) } var regEx = new Regex(expression, RegexOptions); + var match = regEx.Match(formattingInfo.CurrentValue.ToString()); + + if (!match.Success) + { + // Output the "no match" part of the format + if (formats.Count == 2) + formattingInfo.FormatAsChild(formats[1], formattingInfo.CurrentValue); + + return true; + } + + // Match successful - if (formattingInfo.CurrentValue != null && regEx.IsMatch(formattingInfo.CurrentValue.ToString()!)) - formattingInfo.FormatAsChild(formats[0], formattingInfo.CurrentValue); - else if (formats.Count == 2) - formattingInfo.FormatAsChild(formats[1], formattingInfo.CurrentValue); + // If we have child Placeholders in the format, we want to use the matches for the output + var matchingGroupValues = (from Group grp in match.Groups select grp.Value).ToList(); + + // Output the successful match part of the format + foreach (var formatItem in formats[0].Items) + { + if (formatItem is Placeholder ph) + { + var variable = new KeyValuePair(PlaceholderNameForMatches, matchingGroupValues); + Format(formattingInfo, ph, variable); + continue; + } + + // so it must be a literal + var literalText = (LiteralText) formatItem; + // On Dispose() the Format goes back to the object pool + using var childFormat = formattingInfo.Format?.Substring(literalText.StartIndex - formattingInfo.Format.StartIndex, literalText.Length); + if (childFormat is null) continue; + formattingInfo.FormatAsChild(childFormat, formattingInfo.CurrentValue); + } return true; } + private void Format(IFormattingInfo formattingInfo, Placeholder placeholder, object matchingGroupValues) + { + // On Dispose() the Format goes back to the object pool + using var childFormat = + formattingInfo.Format?.Substring(placeholder.StartIndex - formattingInfo.Format.StartIndex, + placeholder.Length); + if (childFormat is null) return; + + // Is the placeholder a "magic IsMatchFormatter" one? + if (placeholder.Selectors.Count > 0 && placeholder.Selectors[0]?.RawText == PlaceholderNameForMatches) + { + // The nested placeholder will output the matching group values + formattingInfo.FormatAsChild(childFormat, matchingGroupValues); + } + else + { + formattingInfo.FormatAsChild(childFormat, formattingInfo.CurrentValue); + } + } + /// /// Gets or sets the for the expression. /// public RegexOptions RegexOptions { get; set; } + + /// + /// Gets or sets the name of the placeholder used to output RegEx matching group values. + /// + /// Example:
+ /// {value:ismatch(regex):First match in '{}'\\: {m[1]}|No match}
+ /// "m" is the PlaceholderNameForMatches + ///
+ ///
+ public string PlaceholderNameForMatches { get; set; } = "m"; + + /// + public void Initialize(SmartFormatter smartFormatter) + { + // The extension is needed to output the values of RegEx matching groups + if (smartFormatter.GetSourceExtension() is null) + smartFormatter.AddExtensions(new KeyValuePairSource()); + } } }