Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 60 additions & 2 deletions CHANGES.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -434,6 +434,7 @@ SmartFormat is the core package. It comes with the most frequently used extensio
* `ValueTupleSource` ✔️
* `ReflectionSource` ✔️
* `DefaultSource` ✔️
* `KeyValuePairSource` ✔️

2) Formatter extensions:

Expand Down Expand Up @@ -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 `<string, object?>`. `KeyValuePair`s may be nested.

Example:
```Csharp
Smart.Format("{placeholder}", new KeyValuePair<string, object?>("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<string, object> 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

Expand Down
113 changes: 72 additions & 41 deletions src/SmartFormat.Tests/Extensions/IsMatchFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,76 +13,106 @@ namespace SmartFormat.Tests.Extensions
[TestFixture]
public class IsMatchFormatterTests
{
private Dictionary<string, object> _variable = new() { {"theKey", "Some123Content"}};
private SmartFormatter _formatter;
private KeyValuePair<string, object> _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<IsMatchFormatter>()!;
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<IsMatchFormatter>()!.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<FormattingException>(() =>
_formatter.Format("{theKey:ismatch(^.+123.+$):Dummy content}", _variable));
Assert.Throws<FormattingException>(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<string, object> { {"theKey", "Some123Content"}};
var smart = Smart.CreateDefaultSmartFormat();
var variable = new Dictionary<string, object> { {"theValue", "Some123Content"}};
var smart = GetFormatter();;
// Set SplitChar from | to TAB, so we can use | for the output string
smart.GetFormatterExtension<IsMatchFormatter>()!.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<string, object> ("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<string, object> ("theValue", "12345");
var smart = GetFormatter();
smart.GetFormatterExtension<IsMatchFormatter>()!.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<int> {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
Expand All @@ -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));
}

Expand All @@ -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");
Expand Down
96 changes: 87 additions & 9 deletions src/SmartFormat/Extensions/IsMatchFormatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
/// <summary>
/// Formatter with evaluation of regular expressions.
/// The formatter can output matching group values.
/// </summary>
/// <remarks>
/// <example>
/// 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 }
/// </remarks>
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}
///
/// </example>
public class IsMatchFormatter : IFormatter, IInitializer
{
/// <summary>
/// Obsolete. <see cref="IFormatter"/>s only have one unique name.
Expand All @@ -41,6 +49,10 @@ public class IsMatchFormatter : IFormatter
///<inheritdoc />
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);

Expand All @@ -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<string, object?>(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);
}
}

/// <summary>
/// Gets or sets the <see cref="RegexOptions"/> for the <see cref="Regex"/> expression.
/// </summary>
public RegexOptions RegexOptions { get; set; }

/// <summary>
/// Gets or sets the name of the placeholder used to output RegEx matching group values.
/// <para>
/// Example:<br/>
/// {value:ismatch(regex):First match in '{}'\\: {m[1]}|No match}<br/>
/// "m" is the PlaceholderNameForMatches
/// </para>
/// </summary>
public string PlaceholderNameForMatches { get; set; } = "m";

///<inheritdoc/>
public void Initialize(SmartFormatter smartFormatter)
{
// The extension is needed to output the values of RegEx matching groups
if (smartFormatter.GetSourceExtension<KeyValuePairSource>() is null)
smartFormatter.AddExtensions(new KeyValuePairSource());
}
}
}