From 631d94b7f6cfee07880958ad20d540b5aa12a385 Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Tue, 21 Jul 2026 20:31:21 +1000 Subject: [PATCH] Allow a stream target for a text extension Since conversion splitters were allowed for text extensions (#1778), a converter registered against a text extension could still not re-emit its source: the Target stream constructor threw for text extensions. eg Verify.ImageMagick returns the svg alongside the rendered png. The stream is now read as text, applying the same newline fixing already used when a stream is read into a text target. --- docs/converter.md | 20 ++++++++++++++ docs/mdsource/converter.source.md | 4 +++ ...extSplitterStream.verified.streamtoconvert | 1 + ...erterTests.TextSplitterStream.verified.txt | 1 + .../Converters/ExtensionConverterTests.cs | 27 +++++++++++++++++++ ...rifyChecksTests.GetExtensions.verified.txt | 1 + src/Verify/IoHelpers.cs | 5 ++++ src/Verify/Target.cs | 26 +++++++++++------- 8 files changed, 76 insertions(+), 9 deletions(-) create mode 100644 src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.streamtoconvert create mode 100644 src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.txt diff --git a/docs/converter.md b/docs/converter.md index 2fd1ea136..024c13ef1 100644 --- a/docs/converter.md +++ b/docs/converter.md @@ -184,6 +184,26 @@ Verify("the source text", "texttoconvert"); snippet source | anchor +A target for a text extension can also be built from a stream. The stream is read as text, so a converter can re-emit its source without converting it back to a string: + + + +```cs +// A target for a text extension can be built from a stream, eg to re-emit the +// source of the conversion. The stream is read as text. +VerifierSettings.RegisterStreamConverter( + "streamtoconvert", + (_, stream, _) => + new( + null, + [ + new("streamtoconvert", stream), + new("txt", "derived from text") + ])); +``` +snippet source | anchor + + ### Cleanup diff --git a/docs/mdsource/converter.source.md b/docs/mdsource/converter.source.md index 0cce35eee..011057741 100644 --- a/docs/mdsource/converter.source.md +++ b/docs/mdsource/converter.source.md @@ -62,6 +62,10 @@ snippet: RegisterStreamConverterTextExtension snippet: TextExtensionConverterVerify +A target for a text extension can also be built from a stream. The stream is read as text, so a converter can re-emit its source without converting it back to a string: + +snippet: RegisterStreamConverterTextExtensionStream + ### Cleanup diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.streamtoconvert b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.streamtoconvert new file mode 100644 index 000000000..925839fe6 --- /dev/null +++ b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.streamtoconvert @@ -0,0 +1 @@ +the source text \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.txt b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.txt new file mode 100644 index 000000000..f925c4ac5 --- /dev/null +++ b/src/Verify.Tests/Converters/ExtensionConverterTests.TextSplitterStream.verified.txt @@ -0,0 +1 @@ +derived from text \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExtensionConverterTests.cs b/src/Verify.Tests/Converters/ExtensionConverterTests.cs index f0c992b7b..c716093e5 100644 --- a/src/Verify.Tests/Converters/ExtensionConverterTests.cs +++ b/src/Verify.Tests/Converters/ExtensionConverterTests.cs @@ -31,6 +31,33 @@ public Task TextSplitter() => Verify("the source text", "texttoconvert"); #endregion + [ModuleInitializer] + public static void TextSplitterStreamInit() + { + FileExtensions.AddTextExtension("streamtoconvert"); + + #region RegisterStreamConverterTextExtensionStream + + // A target for a text extension can be built from a stream, eg to re-emit the + // source of the conversion. The stream is read as text. + VerifierSettings.RegisterStreamConverter( + "streamtoconvert", + (_, stream, _) => + new( + null, + [ + new("streamtoconvert", stream), + new("txt", "derived from text") + ])); + + #endregion + } + + // a conversion splitter for a text extension that re-emits its source stream + [Fact] + public Task TextSplitterStream() => + Verify("the source text", "streamtoconvert"); + [ModuleInitializer] public static void RecursiveInit() => VerifierSettings.RegisterStreamConverter( diff --git a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt index 3956841f0..a35b4bf63 100644 --- a/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt +++ b/src/Verify.Tests/InnerVerifyChecksTests/VerifyChecksTests.GetExtensions.verified.txt @@ -6,6 +6,7 @@ nuspec, props, staticComparerExt, + streamtoconvert, texttoconvert, txt, xml diff --git a/src/Verify/IoHelpers.cs b/src/Verify/IoHelpers.cs index f7f9be26b..cccf73770 100644 --- a/src/Verify/IoHelpers.cs +++ b/src/Verify/IoHelpers.cs @@ -160,6 +160,11 @@ public static async Task ReadStringBuilderWithFixedLines(this Str internal static async Task ReadStringBuilderWithFixedLines(TextReader reader) { var contents = await reader.ReadToEndAsync(); + return contents.ToStringBuilderWithFixedLines(); + } + + internal static StringBuilder ToStringBuilderWithFixedLines(this string contents) + { var builder = new StringBuilder(contents); if (contents.Contains('\r')) { diff --git a/src/Verify/Target.cs b/src/Verify/Target.cs index 6ef58f27a..4fe7664f9 100644 --- a/src/Verify/Target.cs +++ b/src/Verify/Target.cs @@ -57,23 +57,31 @@ public Target(string extension, Stream data, string? name = null, bool performCo { Guards.AgainstBadExtension(extension); + Extension = extension; + Name = FileNameCleaner.SanitizeFilePath(name); + PerformConversion = performConversion; + + // text is always stored as text, so for a text extension the stream is read here. + // eg a converter registered against a text extension re-emitting its source stream. if (FileExtensions.IsTextExtension(extension)) { - throw new( - $""" - Don't pass a stream for text. - If {extension} is not a text extension then use `FileExtensions.RemoveTextExtensions(\"{extension}\")` at initialization; - Otherwise use `Target(string extension, string data)` or `Target(string extension, StringBuilder data, string? name)`. - """); + streamData = null; + stringBuilderData = ReadText(data); + return; } - Extension = extension; - Name = FileNameCleaner.SanitizeFilePath(name); - PerformConversion = performConversion; streamData = data; stringBuilderData = null; } + // the stream is owned by the target, so it is consumed here + static StringBuilder ReadText(Stream stream) + { + stream.MoveToStart(); + using var reader = new StreamReader(stream); + return reader.ReadToEnd().ToStringBuilderWithFixedLines(); + } + public Target(string extension, StringBuilder data, string? name = null) { ValidateExtension(extension);