Skip to content

Commit 7ef11f4

Browse files
authored
Implement ISpanFormattable for DefaultFormatter (#434)
* Initial commit * Implement ISpanFormattable for DefaultFormatter * Remove usage of stack buffer for ISpanFormattable in ZCharArray * Make IFormatProvider an optional argument in ZCharArray.Write overloads * Add unit tests * Unit test ISpanFormattable exceeding initial buffer length * Update class xmldoc * Add missing pragma NET6_0_OR_GREATER * Add unit test for ISpanFormattable exceeding buffer on first attempt * Re-add wiki update checks
1 parent 6874471 commit 7ef11f4

6 files changed

Lines changed: 250 additions & 43 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
name: Check for Wiki Updates
2+
3+
on:
4+
workflow_dispatch: # Allows manual trigger
5+
# push: # Uncomment to run on push
6+
schedule:
7+
- cron: '0 0 * * *' # Runs once a day
8+
9+
jobs:
10+
check-wiki:
11+
if: ${{ github.repository == 'axuno/SmartFormat' }}
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Checkout wiki
15+
uses: actions/checkout@v4
16+
with:
17+
repository: ${{ github.repository }}.wiki
18+
path: wiki # Checkout wiki to wiki folder
19+
20+
- name: Get last commit date, message and number of commits
21+
id: get-info
22+
run: |
23+
cd wiki
24+
DATE=$(git log -1 --format=%cd --date=iso)
25+
FORMATTED_DATE=$(date -d"$DATE" +'%Y-%m-%d %H:%M:%S')
26+
echo "updatedOn=$FORMATTED_DATE" >> $GITHUB_ENV
27+
# The :a;N;$!ba; part buffers the input to ensure that all newline characters are replaced, not just those at the end of each line read
28+
printf "commitMessage=\"%s\"\n" "$(git log -1 --pretty=%B | sed ':a;N;$!ba;s/\n/ ↲ /g' | sed "s/'/\\\\'/g")" >> $GITHUB_ENV
29+
echo "hash=$(git rev-parse HEAD)" >> $GITHUB_ENV
30+
echo "commitCount=$(git rev-list --count --since='24 hours ago' HEAD)" >> $GITHUB_ENV
31+
- name: Print variables
32+
run: |
33+
echo ${{ env.updatedOn }}
34+
echo "${{ env.commitMessage }}"
35+
echo ${{ env.hash }}
36+
echo ${{ env.commitCount }}
37+
- name: Checkout main repo
38+
uses: actions/checkout@v4
39+
with:
40+
repository: ${{ github.repository }}
41+
path: main-repo # Checkout main repo to main-repo folder
42+
token: ${{ secrets.WIKI_CHECK_TOKEN }}
43+
ref: ${{ github.ref }}
44+
- name: Create issue if wiki was updated
45+
id: create-issue
46+
if: ${{ env.commitCount != '0' }}
47+
uses: JasonEtco/create-an-issue@v2.9.2
48+
env:
49+
GITHUB_TOKEN: ${{ secrets.WIKI_CHECK_TOKEN }}
50+
UpdatedOn: ${{ env.updatedOn }}
51+
CommitMessage: ${{ env.commitMessage }}
52+
CommitCount: ${{ env.commitCount }}
53+
Hash: ${{ env.hash }}
54+
with:
55+
filename: main-repo/.github/workflows/Wiki_Update_Issue_Template.md
56+
update_existing: true
57+
search_existing: open
58+
- name: Show issue URL
59+
run: 'echo Created ${{ steps.create-issue.outputs.url }}'
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
name: Wiki Update
3+
about: Issue template for updates of the wiki
4+
title: 'Wiki Update'
5+
labels: 'Docs'
6+
assignees: ''
7+
---
8+
9+
Number of commits
10+
made to the Wiki in the last 24 hours: **{{ env.CommitCount }}**
11+
12+
These are the details of the latest commit:
13+
14+
| Item | Value |
15+
|:---|:---|
16+
| Date | {{ env.UpdatedOn }} |
17+
| Hash | [{{ env.Hash }}](https://github.com/axuno/SmartFormat/wiki/_compare/{{ env.Hash }}) |
18+
| Message | {{ env.commitMessage }} |

src/SmartFormat.Tests/Extensions/DefaultFormatterTests.cs

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,63 @@ public void Call_With_IFormattable_Argument()
6969

7070
#endregion
7171

72+
#region * ISpanFormattable Test *
73+
74+
#if NET6_0_OR_GREATER
75+
[Test]
76+
public void ISpanFormattable_Exceeding_Stackalloc_Buffer()
77+
{
78+
var smart = GetFormatter();
79+
var data = new ISpanFormattableTest();
80+
var result = smart.Format("{0}", data);
81+
82+
Assert.Multiple(() =>
83+
{
84+
// On first attempt, the data will not fit into the buffer
85+
Assert.That(data.TrialNo, Is.GreaterThan(1));
86+
Assert.That(result, Is.EqualTo(data.ToString()));
87+
});
88+
}
89+
90+
internal class ISpanFormattableTest : ISpanFormattable
91+
{
92+
char[] _buffer;
93+
94+
internal int TrialNo;
95+
96+
public ISpanFormattableTest()
97+
{
98+
_buffer = new char[1024];
99+
_buffer.AsSpan().Fill('b');
100+
}
101+
102+
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
103+
{
104+
if (++TrialNo == 1 || destination.Length < _buffer.Length)
105+
{
106+
charsWritten = 0;
107+
return false;
108+
}
109+
110+
_buffer.AsSpan().CopyTo(destination);
111+
charsWritten = _buffer.Length;
112+
return true;
113+
}
114+
115+
public string ToString(string? format, IFormatProvider? formatProvider)
116+
{
117+
return string.Format(formatProvider, format ?? string.Empty, new string(_buffer));
118+
}
119+
120+
public override string ToString()
121+
{
122+
return new string(_buffer);
123+
}
124+
}
125+
#endif
126+
127+
#endregion
128+
72129
#region *** Format with custom formatter ***
73130

74131
[TestCase("format", "value", true)]
@@ -113,8 +170,7 @@ public string Format(string? format, object? arg, IFormatProvider? formatProvide
113170
new string((arg as string ?? "?").Reverse().Select(c => c).ToArray());
114171
}
115172
}
116-
117-
#endregion
173+
#endregion
118174

119175
#region *** FormatDelegate Tests **
120176

@@ -158,4 +214,4 @@ public void FormatDelegate_WithCulture()
158214
}
159215

160216
#endregion
161-
}
217+
}

src/SmartFormat.Tests/ZString/ZCharArrayTests.cs

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,45 @@ public void Buffer_Write_ISpanFormattable()
7070

7171
Assert.That(buffer.ToString(), Is.EqualTo("12.3400"));
7272
}
73+
74+
[Test]
75+
public void ISpanFormattable_Exceeding_BufferLength()
76+
{
77+
var buffer = new ZCharArray(16);
78+
var initialBufferCapacity = buffer.Capacity;
79+
var data = new SpanFormattable();
80+
buffer.Write(data, Span<char>.Empty);
81+
82+
Assert.Multiple(() =>
83+
{
84+
// On first attempt, the data will not fit into the buffer
85+
Assert.That(data.TrialNo, Is.GreaterThan(1));
86+
Assert.That(buffer.Capacity, Is.GreaterThan(initialBufferCapacity));
87+
});
88+
}
89+
90+
internal record SpanFormattable : ISpanFormattable
91+
{
92+
internal int TrialNo;
93+
94+
public string ToString(string? format, IFormatProvider? formatProvider)
95+
{
96+
return nameof(SpanFormattable);
97+
}
98+
99+
public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider)
100+
{
101+
if (++TrialNo == 1)
102+
{
103+
charsWritten = 0;
104+
return false;
105+
}
106+
107+
destination.Fill('#');
108+
charsWritten = destination.Length;
109+
return true;
110+
}
111+
}
73112
#endif
74113

75114
[Test]
@@ -96,7 +135,7 @@ public void Buffer_Reset_Size_To_Zero()
96135
Assert.That(buffer.Length, Is.EqualTo(0));
97136
});
98137
}
99-
138+
100139
[Test]
101140
public void Buffer_Thread_Safety()
102141
{

src/SmartFormat/Extensions/DefaultFormatter.cs

Lines changed: 63 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,37 @@
77

88
namespace SmartFormat.Extensions;
99

10+
#if NET6_0_OR_GREATER
1011
/// <summary>
11-
/// Do the default formatting, same logic as "String.Format".
12+
/// Does the default formatting.
13+
/// This formatter in always required, unless you implement your own.
14+
/// <pre/>
15+
/// It supports <see cref="ISpanFormattable"/>, <see cref="IFormattable"/>, and <see cref="ICustomFormatter"/>.
1216
/// </summary>
17+
#else
18+
/// <summary>
19+
/// Does the default formatting.
20+
/// This formatter in always required, unless you implement your own.
21+
/// <pre/>
22+
/// It supports <see cref="IFormattable"/> and <see cref="ICustomFormatter"/>.
23+
/// </summary>
24+
#endif
1325
public class DefaultFormatter : IFormatter
1426
{
27+
28+
#if NET6_0_OR_GREATER
29+
/// <summary>
30+
/// The maximum size of the stack-allocated buffer
31+
/// for formatting <see cref="System.ISpanFormattable"/> objects.
32+
/// </summary>
33+
internal const int StackAllocCharBufferSize = 512;
34+
#endif
35+
1536
/// <summary>
1637
/// Obsolete. <see cref="IFormatter"/>s only have one unique name.
1738
/// </summary>
1839
[Obsolete("Use property \"Name\" instead", true)]
19-
public string[] Names { get; set; } = {"default", "d", string.Empty};
40+
public string[] Names { get; set; } = { "default", "d", string.Empty };
2041

2142
///<inheritdoc/>
2243
public string Name { get; set; } = "d";
@@ -42,37 +63,57 @@ public bool TryEvaluateFormat(IFormattingInfo formattingInfo)
4263
return true;
4364
}
4465

45-
// Use the provider to see if a CustomFormatter is available:
46-
var provider = formattingInfo.FormatDetails.Provider;
66+
/*
67+
* The order of precedence is:
68+
* 1. ICustomFormatter from the IFormatProvider
69+
* 2. ISpanFormattable (for .NET 6.0 or later)
70+
* 3. IFormattable
71+
* 4. ToString
72+
*/
4773

48-
// We will try using IFormatProvider, IFormattable, and if all else fails, ToString.
49-
string? result;
74+
var provider = formattingInfo.FormatDetails.Provider;
5075
if (provider?.GetFormat(typeof(ICustomFormatter)) is ICustomFormatter cFormatter)
5176
{
5277
var formatText = format?.GetLiteralText();
53-
result = cFormatter.Format(formatText, current, provider);
54-
}
55-
// IFormattable
56-
// Note: This is what ValueStringBuilder is implementing in the same way
57-
else if (current is IFormattable formattable)
58-
{
59-
var formatText = format?.ToString();
60-
result = formattable.ToString(formatText, provider);
78+
formattingInfo.Write(cFormatter.Format(formatText, current, provider).AsSpan());
79+
return true;
6180
}
62-
else if (current is string str)
81+
82+
#if NET6_0_OR_GREATER
83+
if (current is ISpanFormattable spanFormattable)
6384
{
64-
formattingInfo.Write(str.AsSpan());
85+
// ISpanFormattable has the same speed as IFormattable,
86+
// but brings less GC pressure (e.g. 25% less for processing 1234567.890123f).
87+
88+
var fmtTextSpan = format != null ? format.AsSpan() : Span<char>.Empty;
89+
90+
// Try to use the stack buffer first
91+
Span<char> buffer = stackalloc char[StackAllocCharBufferSize];
92+
93+
if (spanFormattable.TryFormat(buffer, out var written, fmtTextSpan, provider))
94+
{
95+
formattingInfo.Write(buffer.Slice(0, written));
96+
return true;
97+
}
98+
99+
// If the stack buffer is too small, use a heap buffer
100+
using var arrayBuffer = new ZString.ZCharArray(2_000_000);
101+
arrayBuffer.Write(spanFormattable, fmtTextSpan, provider);
102+
formattingInfo.Write(arrayBuffer.GetSpan());
65103
return true;
66104
}
67-
// ToString:
68-
else
105+
#endif
106+
107+
if (current is IFormattable formattable)
69108
{
70-
result = current?.ToString();
109+
var fmtTextString = format?.ToString();
110+
formattingInfo.Write(formattable.ToString(fmtTextString, provider).AsSpan());
111+
return true;
71112
}
72113

73-
// Output the result:
74-
formattingInfo.Write(result != null ? result.AsSpan() : ReadOnlySpan<char>.Empty);
75-
114+
// Fallback to ToString (string.ToString() returns 'this')
115+
var result = current != null ? current.ToString().AsSpan() : Span<char>.Empty;
116+
formattingInfo.Write(result);
76117
return true;
77118
}
78119
}

src/SmartFormat/ZString/ZCharArray.cs

Lines changed: 11 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,6 @@ private static readonly ArrayPool<char>
3333
/// </summary>
3434
public const int MaxBufferCapacity = 10_000_000;
3535

36-
/// <summary>
37-
/// The maximum size of the stack-allocated buffer.
38-
/// </summary>
39-
public const int StackAllocCharBufferSizeLimit = 256;
40-
4136
/// <summary>
4237
/// Creates a new <see cref="ZCharArray"/> with a length of <see cref="DefaultBufferCapacity"/>>.
4338
/// </summary>
@@ -205,22 +200,22 @@ public void Write(char c, int count)
205200
/// <param name="data"></param>
206201
/// <param name="format"></param>
207202
/// <param name="provider"></param>
208-
/// <exception cref="FormatException"></exception>
209203
/// <exception cref="ObjectDisposedException"></exception>
210-
public void Write(ISpanFormattable data, ReadOnlySpan<char> format, IFormatProvider provider)
204+
public void Write(ISpanFormattable data, ReadOnlySpan<char> format, IFormatProvider? provider = null)
211205
{
212206
ThrowIfDisposed();
213207

214-
Span<char> stackBuffer = stackalloc char[StackAllocCharBufferSizeLimit];
215-
if (data.TryFormat(stackBuffer, out var written, format, provider))
208+
// Increases the buffer size until it's big enough
209+
while (true)
216210
{
217-
GrowBufferIfNeeded(written);
218-
stackBuffer.Slice(0, written).CopyTo(_bufferArray!.AsSpan(_currentLength));
219-
_currentLength += written;
220-
return;
221-
}
211+
if (data.TryFormat(_bufferArray.AsSpan(_currentLength), out var written, format, provider))
212+
{
213+
_currentLength += written;
214+
return;
215+
}
222216

223-
throw new FormatException("The data could not be formatted.");
217+
GrowBufferIfNeeded(1_000);
218+
}
224219
}
225220
#endif
226221

@@ -231,9 +226,8 @@ public void Write(ISpanFormattable data, ReadOnlySpan<char> format, IFormatProvi
231226
/// <param name="data"></param>
232227
/// <param name="format"></param>
233228
/// <param name="provider"></param>
234-
/// <exception cref="FormatException"></exception>
235229
/// <exception cref="ObjectDisposedException"></exception>
236-
public void Write(IFormattable data, string format, IFormatProvider provider)
230+
public void Write(IFormattable data, string format, IFormatProvider? provider = null)
237231
{
238232
ThrowIfDisposed();
239233
var formatted = data.ToString(format, provider).AsSpan();

0 commit comments

Comments
 (0)