Skip to content

Commit

Permalink
Better separation of built-in string.Format and Smart.Format features (
Browse files Browse the repository at this point in the history
…axuno#175)

#### Better separation of built-in string.Format and Smart.Format features

1. Settings.StringFormatCompatibility == true

* In this mode, *SmartFormat* is a drop-in replacement for `string.Format` regarding formatting syntax.
* Curly braces `{}` **must** be escaped with double braces `{{` and `}}`
* The `Parser` will not include the formatter name or formatting options. Like with `string.Format`, everything after the `Selector` separator (colon) is considered as format specifier.
* `DefaultFormatter` is the only formatter which will be invoked.
* Even in compatibility mode, *SmartFormat* will
  * Process named `Placeholder`s (beside indexed `Placeholder`s)
  * have `ISource`s available for `Placeholder`s.
  * be able to process escaped string literals like \n, \U1234 etc.

2. Settings.StringFormatCompatibility == false

* Makes full use of all *SmartFormat* features
* Any character can be used, as long as few special characters are escaped with a backslash `\`. These are e.g. `{}()[]():.`.
* This leads to a slightly different format string syntax. Example: `string.Format("It is now {0:yyyy/MM/dd HH:mm:ss}, DateTime.Now)` vs. `Smart.Format("It is now {Date:yyyy/MM/dd HH\:mm\:ss}, DateTime.Now)`.

3. All modes

* It is possible to use a `System.IFormatProvider` as argument to `Smart.Format` - same as with `string.Format`. This custom format provider can in turn call a `System.ICustomFormatter` for special formatting needs.
* This feature is implemented in the `DefaultFormatter`.
  • Loading branch information
axunonb committed Mar 9, 2022
1 parent b5c2cd3 commit f414506
Show file tree
Hide file tree
Showing 12 changed files with 204 additions and 108 deletions.
3 changes: 2 additions & 1 deletion src/SmartFormat.Tests/Core/FormatterTests.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using NUnit.Framework;
using SmartFormat.Core.Formatting;
using SmartFormat.Core.Output;
Expand Down Expand Up @@ -140,7 +141,7 @@ public void LeadingBackslashMustNotEscapeBraces()
{
var smart = Smart.CreateDefaultSmartFormat();
smart.Settings.Parser.ConvertCharacterStringLiterals = false;
smart.Settings.UseStringFormatCompatibility = true;
smart.Settings.StringFormatCompatibility = true;

var expected = "\\Hello";
var actual = smart.Format("\\{Test}", new { Test = "Hello" });
Expand Down
126 changes: 74 additions & 52 deletions src/SmartFormat.Tests/Core/ParserTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,16 @@ namespace SmartFormat.Tests.Core
[TestFixture]
public class ParserTests
{
private static Parser GetRegularParser()
{
var parser = new SmartFormatter { Settings = { StringFormatCompatibility = false, Parser = {ErrorAction = ParseErrorAction.ThrowError }}}.Parser;
return parser;
}

[Test]
public void TestParser()
public void Basic_Parser_Test()
{
var parser = new SmartFormatter {Settings = { Parser = {ErrorAction = ParseErrorAction.ThrowError}}}.Parser;
parser.AddAlphanumericSelectors();
parser.AddAdditionalSelectorChars("_");
parser.AddOperators(".");
var parser = GetRegularParser();

var formats = new[]{
" aaa {bbb.ccc: ddd {eee} fff } ggg ",
Expand Down Expand Up @@ -89,7 +92,9 @@ public void Parser_Exception_ErrorDescription()
[Test]
public void Parser_Ignores_Exceptions()
{
var parser = new SmartFormatter() { Settings = { Parser = {ErrorAction = ParseErrorAction.Ignore }}}.Parser;
var parser = GetRegularParser();
parser.Settings.Parser.ErrorAction = ParseErrorAction.Ignore;

var invalidFormats = new[] {
"{",
"{0",
Expand All @@ -116,9 +121,6 @@ public void Parser_Error_Action_Ignore()
// | Literal | Erroneous | | Okay |
var invalidTemplate = "Hello, I'm {Name from {City} {Street}";

var smart = Smart.CreateDefaultSmartFormat();
smart.Settings.Parser.ErrorAction = ParseErrorAction.Ignore;

var parser = GetRegularParser();
parser.Settings.Parser.ErrorAction = ParseErrorAction.Ignore;
var parsed = parser.ParseFormat(invalidTemplate);
Expand Down Expand Up @@ -309,12 +311,6 @@ .comment img {
"NO placeholder");
}
}

private static Parser GetRegularParser()
{
var parser = new SmartFormatter() { Settings = { Parser = {ErrorAction = ParseErrorAction.ThrowError }}}.Parser;
return parser;
}

[Test]
public void Test_Format_Substring()
Expand Down Expand Up @@ -402,22 +398,22 @@ public void Test_Format_IndexOf()
{
var parser = GetRegularParser();
var format = " a|aa {bbb: ccc dd|d {:|||} {eee} ff|f } gg|g ";
var Format = parser.ParseFormat(format);
var result = parser.ParseFormat(format);

Assert.That(Format.IndexOf('|'), Is.EqualTo(2));
Assert.That(Format.IndexOf('|', 3), Is.EqualTo(43));
Assert.That(Format.IndexOf('|', 44), Is.EqualTo(-1));
Assert.That(Format.IndexOf('#'), Is.EqualTo(-1));
Assert.That(result.IndexOf('|'), Is.EqualTo(2));
Assert.That(result.IndexOf('|', 3), Is.EqualTo(43));
Assert.That(result.IndexOf('|', 44), Is.EqualTo(-1));
Assert.That(result.IndexOf('#'), Is.EqualTo(-1));

// Test nested formats:
var placeholder = (Placeholder) Format.Items[1];
Format = placeholder.Format!;
Assert.That(Format.ToString(), Is.EqualTo(" ccc dd|d {:|||} {eee} ff|f "));

Assert.That(Format.IndexOf('|'), Is.EqualTo(7));
Assert.That(Format.IndexOf('|', 8), Is.EqualTo(25));
Assert.That(Format.IndexOf('|', 26), Is.EqualTo(-1));
Assert.That(Format.IndexOf('#'), Is.EqualTo(-1));
var placeholder = (Placeholder) result.Items[1];
result = placeholder.Format!;
Assert.That(result.ToString(), Is.EqualTo(" ccc dd|d {:|||} {eee} ff|f "));

Assert.That(result.IndexOf('|'), Is.EqualTo(7));
Assert.That(result.IndexOf('|', 8), Is.EqualTo(25));
Assert.That(result.IndexOf('|', 26), Is.EqualTo(-1));
Assert.That(result.IndexOf('#'), Is.EqualTo(-1));
}

[Test]
Expand Down Expand Up @@ -461,22 +457,23 @@ private Format Parse(string format, string[] formatterExentionNames )
public void Name_of_registered_NamedFormatter_will_be_parsed(string format, string expectedName, string expectedOptions, string expectedFormat)
{
// The parser will only find names of named formatters which are registered. Names are case-sensitive.
var formatterExtensions = new[] { "name" };
var parser = GetRegularParser();

// Named formatters will only be recognized by the parser, if their name occurs in one of FormatterExtensions.
// If the name of the formatter does not exists, the string is treaded as format for the DefaultFormatter.
var placeholder = (Placeholder) Parse(format, formatterExtensions).Items[0];
var placeholder = (Placeholder) parser.ParseFormat(format).Items[0];
Assert.AreEqual(expectedName, placeholder.FormatterName);
Assert.AreEqual(expectedOptions, placeholder.FormatterOptions);
Assert.AreEqual(expectedFormat, placeholder.Format!.ToString());
}

[Test]
public void Name_of_unregistered_NamedFormatter_will_not_be_parsed()
public void Name_of_unregistered_NamedFormatter_will_be_parsed()
{
// find formatter formattername, which does not exist in the (empty) list of formatter extensions
var placeholderWithNonExistingName = (Placeholder)Parse("{0:formattername:}", new string[] {} ).Items[0];
Assert.AreEqual("formattername", placeholderWithNonExistingName.FormatterName); // name is only treaded as a literal
// find formatter formatter name, which does not exist in the (empty) list of formatter extensions
var parser = GetRegularParser();
var placeholderWithNonExistingName = (Placeholder)parser.ParseFormat("{0:formattername:}").Items[0];
Assert.AreEqual("formattername", placeholderWithNonExistingName.FormatterName);
}

// Incomplete:
Expand Down Expand Up @@ -506,7 +503,16 @@ public void Name_of_unregistered_NamedFormatter_will_not_be_parsed()
public void NamedFormatter_should_be_null_when_empty_or_invalid_or_escaped(string format)
{
var expectedLiteralText = format.Substring(3, format.Length - 3 - 1);
AssertNoNamedFormatter(format, expectedLiteralText);

var parser = GetRegularParser();
parser.Settings.Parser.ConvertCharacterStringLiterals = false;

var placeholder = (Placeholder) parser.ParseFormat(format).Items[0];
var literalText = placeholder.Format?.GetLiteralText();

Assert.That(placeholder.FormatterName, Is.Empty);
Assert.That(placeholder.FormatterOptions, Is.Empty);
Assert.That(literalText, Is.EqualTo(expectedLiteralText));
}

[TestCase(@"{0:format{}}", "format")]
Expand All @@ -516,50 +522,66 @@ public void NamedFormatter_should_be_null_when_empty_or_invalid_or_escaped(strin
[TestCase(@"{0:for{}mat()}", "format()")]
[TestCase(@"{0:for(){}mat}", "for()mat")]
public void NamedFormatter_should_be_null_when_has_nesting(string format, string expectedLiteralText)
{
AssertNoNamedFormatter(format, expectedLiteralText);
}

private void AssertNoNamedFormatter(string format, string expectedLiteralText)
{
var parser = GetRegularParser();
parser.UseAlternativeEscapeChar('\\');
parser.Settings.ConvertCharacterStringLiterals = false;
parser.Settings.Parser.ConvertCharacterStringLiterals = false;

var placeholder = (Placeholder) parser.ParseFormat(format).Items[0];
Assert.IsEmpty(placeholder.FormatterName);
Assert.IsEmpty(placeholder.FormatterOptions);
var literalText = placeholder.Format?.GetLiteralText();
Assert.AreEqual(expectedLiteralText, literalText);
}

Assert.That(placeholder.FormatterName, Is.Empty);
Assert.That(placeholder.FormatterOptions, Is.Empty);
Assert.That(literalText, Is.EqualTo(expectedLiteralText));
}

[Test]
public void Parser_NotifyParsingError()
{
ParsingErrors? parsingError = null;
var formatter = Smart.CreateDefaultSmartFormat();
formatter.Settings.Formatter.ErrorAction = FormatErrorAction.Ignore;
formatter.Settings.Parser.ErrorAction = ParseErrorAction.Ignore;

formatter.Parser.OnParsingFailure += (o, args) => parsingError = args.Errors;
var res = formatter.Format("{NoName {Other} {Same", default(object)!);
Assert.That(parsingError!.Issues.Count == 3);
Assert.That(parsingError.Issues[2].Issue == new SmartFormat.Core.Parsing.Parser.ParsingErrorText()[SmartFormat.Core.Parsing.Parser.ParsingError.MissingClosingBrace]);

Assert.That(parsingError!.Issues.Count, Is.EqualTo(3));
Assert.That(parsingError.Issues[2].Issue, Is.EqualTo(new Parser.ParsingErrorText()[SmartFormat.Core.Parsing.Parser.ParsingError.MissingClosingBrace]));
}

[Test]
public void Missing_Curly_Brace_Should_Throw()
{
var parser = GetRegularParser();
var format = "{0:yyyy/MM/dd HH:mm:ss";

Assert.That(() => parser.ParseFormat(format),
Throws.Exception.InstanceOf(typeof(ParsingErrors)).And.Message
.Contains(new Parser.ParsingErrorText()[Parser.ParsingError.MissingClosingBrace]));
}

[Test]
public void Alternative_Escaping_In_Literal()
public void Literal_Escaping_In_Literal()
{
var parser = GetRegularParser();
parser.UseAlternativeEscapeChar('\\');
parser.Settings.StringFormatCompatibility = false;
Assert.That(parser.ParseFormat("\\{\\}").ToString(), Is.EqualTo("{}"));
}

[Test]
public void Nested_format_with_alternative_escaping()
public void StringFormat_Escaping_In_Literal()
{
var parser = GetRegularParser();
parser.Settings.StringFormatCompatibility = true;
Assert.That(parser.ParseFormat("{{}}").ToString(), Is.EqualTo("{}"));
}


[Test]
public void Nested_format_with_literal_escaping()
{
var parser = GetRegularParser();
// necessary because of the consecutive }}}, which would otherwise be escaped as }} and lead to "missing brace" exception:
parser.UseAlternativeEscapeChar('\\');
var placeholders = parser.ParseFormat("{c1:{c2:{c3}}}");

var c1 = (Placeholder) placeholders.Items[0];
Expand Down
22 changes: 15 additions & 7 deletions src/SmartFormat.Tests/Core/StringFormatCompatibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public class StringFormatCompatibilityTests
[SetUp]
public void Setup()
{
_formatter.Settings.UseStringFormatCompatibility = true;
_formatter.Settings.StringFormatCompatibility = true;
}

[Test]
Expand Down Expand Up @@ -58,7 +58,6 @@ public void NamedPlaceholderDateTimeHHmmss()
var smartFmt = "It is now {Date:yyyy/MM/dd HH:mm:ss}";
var stringFmt = $"It is now {now.Date:yyyy/MM/dd HH:mm:ss}";
Assert.That(_formatter.Format(smartFmt, now), Is.EqualTo(stringFmt));

}

[Test]
Expand Down Expand Up @@ -92,7 +91,7 @@ public void NamedPlaceholderDecimal()
[Test]
public void NamedPlaceholderDateTime()
{
var now = DateTime.Now;
var now = new DateTime(2021, 12, 22, 14, 18, 12);
var smartFmt = "It is now {Date:d} at {Date:t}";
var stringFmt = $"It is now {now.Date:d} at {now.Date:t}";

Expand All @@ -112,7 +111,7 @@ public void NamedPlaceholderAlignment()
[Test]
public void NamedPlaceholder_DecimalArg()
{
_formatter.Settings.UseStringFormatCompatibility = false;
_formatter.Settings.StringFormatCompatibility = false;
var pricePerOunce = 17.36m;
var format = "The current price is {0} per ounce.";

Expand All @@ -122,7 +121,7 @@ public void NamedPlaceholder_DecimalArg()
[Test]
public void NamedPlaceholder_DecimalCurrencyArg()
{
_formatter.Settings.UseStringFormatCompatibility = false;
_formatter.Settings.StringFormatCompatibility = false;
var pricePerOunce = 17.36m;
var format = "The current price is {0:C2} per ounce.";

Expand All @@ -134,9 +133,18 @@ public void NamedPlaceholder_DecimalCurrencyArg()
/// </summary>
[TestCase("{0:FormatterName(true|false):one|two|default}", true)]
[TestCase("{0:FormatterName(string|String):one|two|default}", "String")]
public void Choose_should_be_case_sensitive(string format, object arg0)
[TestCase("{0,10:FormatterName(string|String):one|two|default}", "value")]
[TestCase("{0:d:FormatterName(string|String):one|two|default}", "2021-12-01")]
public void FormatterName_And_Options_Should_Be_Ignored(string format, object arg0)
{
Assert.That(_formatter.Format(format, arg0), Is.EqualTo(string.Format(format, arg0)));
}

[TestCase("{0:yyyy/MM/dd HH:mm:ss FormatterName(string|String):one|two|default}", "2021-12-01")] // results in "nonsense"
[TestCase("{0:yyyy/MM/dd HH:mm:ss}", "2021-12-01")]
public void FormatterName_And_Options_Should_Be_Ignored2(string format, DateTime arg0)
{
Assert.That(_formatter.Format(format, arg0), Does.StartWith("FormatterName"));
Assert.That(_formatter.Format(format, arg0), Is.EqualTo(string.Format(format, arg0)));
}
}
}
2 changes: 1 addition & 1 deletion src/SmartFormat.Tests/Extensions/ListFormatterTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public void List_of_anonymous_types_and_enumerables()
Persons = data.Where(p => p.Gender == "M")
};

Smart.Default.Settings.UseStringFormatCompatibility = false; // mandatory for this test case because of consecutive curly braces
Smart.Default.Settings.StringFormatCompatibility = false; // mandatory for this test case because of consecutive curly braces
Smart.Default.Settings.Formatter.ErrorAction = SmartFormat.Core.Settings.FormatErrorAction.ThrowError;
Smart.Default.Settings.Parser.ErrorAction = SmartFormat.Core.Settings.ParseErrorAction.ThrowError;

Expand Down
4 changes: 2 additions & 2 deletions src/SmartFormat.Tests/Extensions/XmlSourceTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ public void Format_XmlWithNamespaces_IgnoringNamespace()
public void Format_SingleLevelXml_TemplateWithCurlyBraces_Escaped()
{
var sf = Smart.CreateDefaultSmartFormat();
sf.Settings.UseStringFormatCompatibility = true;
sf.Settings.StringFormatCompatibility = false;
// arrange
var xmlEl = XElement.Parse(OneLevelXml);
// act
var res = sf.Format("Mr. {{{LastName}}}", xmlEl);
var res = sf.Format("Mr. \\{{LastName}\\}", xmlEl);
// assert
Assert.AreEqual("Mr. {Doe}", res);
}
Expand Down
68 changes: 68 additions & 0 deletions src/SmartFormat.Tests/Utilities/CustomFormatProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using NUnit.Framework;
using SmartFormat.Extensions;

namespace SmartFormat.Tests.Utilities
{
[TestFixture]
public class CustomFormatProviderTests
{
private SmartFormatter GetSimpleFormatter()
{
var formatter = new SmartFormatter();
formatter.FormatterExtensions.Add(new DefaultFormatter());
formatter.SourceExtensions.Add(new ReflectionSource(formatter));
formatter.SourceExtensions.Add(new DefaultSource(formatter));
return formatter;
}

#region *** Format with custom formatter ***

[TestCase("format", "value", true)]
[TestCase("tamrof", "eulav", true)]
[TestCase("format", "value", false)]
[TestCase("tamrof", "eulav", false)]
public void Format_With_CustomFormatter(string format, string value, bool stringFormatCompatible)
{
var smart = GetSimpleFormatter();
smart.Settings.StringFormatCompatibility = stringFormatCompatible;
var expected = new string(format.Reverse().Select(c => c).ToArray()) + ": " +
new string(value.Reverse().Select(c => c).ToArray());
var resultSmartFormat = smart.Format(new ReverseFormatProvider(), $"{{0:{format}}}", value);
var resultStringFormat = string.Format(new ReverseFormatProvider(), $"{{0:{format}}}", value);
Assert.That(resultSmartFormat, Is.EqualTo(expected));
Assert.That(resultStringFormat, Is.EqualTo(expected));
}

/// <summary>
/// Used for Format_With_CustomFormatter test
/// </summary>
public class ReverseFormatProvider : IFormatProvider
{
public object GetFormat(Type formatType)
{
if (formatType == typeof(ICustomFormatter)) return new ReverseFormatAndArgumentFormatter();

return new object();
}
}

/// <summary>
/// Used for Format_With_CustomFormatter test
/// </summary>
public class ReverseFormatAndArgumentFormatter : ICustomFormatter
{
public string Format(string format, object arg, IFormatProvider formatProvider)
{
return new string(format.Reverse().Select(c => c).ToArray()) + ": " +
new string((arg as string ?? "?").Reverse().Select(c => c).ToArray());
}
}

#endregion
}
}
Loading

0 comments on commit f414506

Please sign in to comment.