diff --git a/CodeBlocker.Test/TemplateTests.cs b/CodeBlocker.Test/TemplateTests.cs
index 5c40f52..cebf078 100644
--- a/CodeBlocker.Test/TemplateTests.cs
+++ b/CodeBlocker.Test/TemplateTests.cs
@@ -2,6 +2,7 @@
namespace CodeBlocker.Tests;
+using System.Text;
using ktsu.CodeBlocker;
using ktsu.CodeBlocker.Templates;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -48,6 +49,31 @@ public void ParameterEscapesAQuotedDefaultValue(string value, string expectedLit
$"string name = {expectedLiteral}",
Render(new ParameterTemplate { Type = "string", Name = "name", DefaultValue = value, DefaultValueIsQuoted = true }));
+ [TestMethod]
+ public void ParameterEscapesALoneSurrogateInAQuotedDefaultValue()
+ {
+ // These are built in code rather than passed through DataRow: an attribute argument is
+ // stored as UTF-8, which cannot carry a lone surrogate either.
+ (string Value, string ExpectedLiteral)[] cases =
+ [
+ ("a\uD800b", @"""a\ud800b"""),
+ ("a\uDC00b", @"""a\udc00b"""),
+ ("end\uD83D", @"""end\ud83d"""),
+ ("\uDE00start", @"""\ude00start"""),
+ ("\uDE00\uD83D", @"""\ude00\ud83d"""),
+ ("pair \uD83D\uDE00", "\"pair \uD83D\uDE00\""),
+ ];
+
+ UTF8Encoding strictUtf8 = new(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+ foreach ((string value, string expectedLiteral) in cases)
+ {
+ string rendered = Render(new ParameterTemplate { Type = "string", Name = "name", DefaultValue = value, DefaultValueIsQuoted = true });
+
+ Assert.AreEqual($"string name = {expectedLiteral}", rendered);
+ _ = strictUtf8.GetBytes(rendered);
+ }
+ }
+
[TestMethod]
public void ParameterKeepsItsAttributesOnTheDeclarationLine()
{
diff --git a/CodeBlocker/Templates/TemplateRendering.cs b/CodeBlocker/Templates/TemplateRendering.cs
index 017955e..11bf57b 100644
--- a/CodeBlocker/Templates/TemplateRendering.cs
+++ b/CodeBlocker/Templates/TemplateRendering.cs
@@ -125,15 +125,18 @@ internal static void SpliceFragment(CodeBlocker parent, string fragment)
/// The quoted literal.
///
/// Backslashes, quotes and the common control characters take their short escapes; any other
- /// control character, and the line and paragraph separators the compiler also treats as line
- /// breaks, take a \uXXXX escape.
+ /// control character, the line and paragraph separators the compiler also treats as line
+ /// breaks, and any surrogate that is not half of a valid pair take a \uXXXX escape. A
+ /// lone surrogate has no UTF-8 encoding, so written raw it would make the generated file
+ /// impossible to save faithfully: a strict encoder throws and a replacing one changes the value.
///
internal static string QuoteStringLiteral(string value)
{
StringBuilder literal = new(value.Length + 2);
literal.Append('"');
- foreach (char c in value)
+ for (int i = 0; i < value.Length; i++)
{
+ char c = value[i];
string? escape = c switch
{
'\\' => @"\\",
@@ -146,7 +149,7 @@ internal static string QuoteStringLiteral(string value)
'\r' => @"\r",
'\t' => @"\t",
'\v' => @"\v",
- _ when char.IsControl(c) || c is '\u2028' or '\u2029' =>
+ _ when char.IsControl(c) || c is '\u2028' or '\u2029' || IsLoneSurrogate(value, i) =>
@"\u" + ((int)c).ToString("x4", CultureInfo.InvariantCulture),
_ => null,
};
@@ -165,6 +168,29 @@ _ when char.IsControl(c) || c is '\u2028' or '\u2029' =>
return literal.ToString();
}
+ ///
+ /// Reports whether the character at is a surrogate that is not half of
+ /// a high-low pair.
+ ///
+ /// The text.
+ /// The position of the character to test.
+ /// for an unpaired surrogate; otherwise .
+ private static bool IsLoneSurrogate(string value, int index)
+ {
+ char c = value[index];
+ if (char.IsHighSurrogate(c))
+ {
+ return index + 1 >= value.Length || !char.IsLowSurrogate(value[index + 1]);
+ }
+
+ if (char.IsLowSurrogate(c))
+ {
+ return index == 0 || !char.IsHighSurrogate(value[index - 1]);
+ }
+
+ return false;
+ }
+
///
/// Writes a parenthesised, comma-separated parameter list.
///