diff --git a/Makefile.am b/Makefile.am index 7104c3c0ee..acc72c27ef 100644 --- a/Makefile.am +++ b/Makefile.am @@ -131,6 +131,7 @@ csharp_EXTRA_DIST= \ csharp/src/Google.Protobuf.Test/GeneratedMessageTest.Proto2.cs \ csharp/src/Google.Protobuf.Test/Google.Protobuf.Test.csproj \ csharp/src/Google.Protobuf.Test/IssuesTest.cs \ + csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs \ csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs \ csharp/src/Google.Protobuf.Test/JsonParserTest.cs \ csharp/src/Google.Protobuf.Test/JsonTokenizerTest.cs \ diff --git a/csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs b/csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs new file mode 100644 index 0000000000..f7ea97c135 --- /dev/null +++ b/csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs @@ -0,0 +1,111 @@ +#region Copyright notice and license + +// Protocol Buffers - Google's data interchange format +// Copyright 2008 Google Inc. All rights reserved. +// https://developers.google.com/protocol-buffers/ +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are +// met: +// +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above +// copyright notice, this list of conditions and the following disclaimer +// in the documentation and/or other materials provided with the +// distribution. +// * Neither the name of Google Inc. nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +// OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +#endregion + +using Google.Protobuf.Reflection; +using NUnit.Framework; + +// For WrapInQuotes + +namespace Google.Protobuf +{ + public class JsonFormatterSettingsTest + { + [Test] + public void WithIndentation() + { + var settings = JsonFormatter.Settings.Default.WithIndentation("\t"); + Assert.AreEqual("\t", settings.Indentation); + } + + [Test] + public void WithTypeRegistry() + { + var typeRegistry = TypeRegistry.Empty; + var settings = JsonFormatter.Settings.Default.WithTypeRegistry(typeRegistry); + Assert.AreEqual(typeRegistry, settings.TypeRegistry); + } + + [Test] + public void WithFormatDefaultValues() + { + var settingsWith = JsonFormatter.Settings.Default.WithFormatDefaultValues(true); + Assert.AreEqual(true, settingsWith.FormatDefaultValues); + + var settingsWithout = JsonFormatter.Settings.Default.WithFormatDefaultValues(false); + Assert.AreEqual(false, settingsWithout.FormatDefaultValues); + } + + [Test] + public void WithFormatEnumsAsIntegers() + { + var settingsWith = JsonFormatter.Settings.Default.WithFormatEnumsAsIntegers(true); + Assert.AreEqual(true, settingsWith.FormatEnumsAsIntegers); + + var settingsWithout = JsonFormatter.Settings.Default.WithFormatEnumsAsIntegers(false); + Assert.AreEqual(false, settingsWithout.FormatEnumsAsIntegers); + } + + [Test] + public void WithMethodsPreserveExistingSettings() + { + var typeRegistry = TypeRegistry.Empty; + var baseSettings = JsonFormatter.Settings.Default + .WithIndentation("\t") + .WithFormatDefaultValues(true) + .WithFormatEnumsAsIntegers(true) + .WithTypeRegistry(typeRegistry) + .WithPreserveProtoFieldNames(true); + + var settings1 = baseSettings.WithIndentation("\t"); + var settings2 = baseSettings.WithFormatDefaultValues(true); + var settings3 = baseSettings.WithFormatEnumsAsIntegers(true); + var settings4 = baseSettings.WithTypeRegistry(typeRegistry); + var settings5 = baseSettings.WithPreserveProtoFieldNames(true); + + AssertAreEqual(baseSettings, settings1); + AssertAreEqual(baseSettings, settings2); + AssertAreEqual(baseSettings, settings3); + AssertAreEqual(baseSettings, settings4); + AssertAreEqual(baseSettings, settings5); + } + + private static void AssertAreEqual(JsonFormatter.Settings settings, JsonFormatter.Settings other) + { + Assert.AreEqual(settings.Indentation, other.Indentation); + Assert.AreEqual(settings.FormatDefaultValues, other.FormatDefaultValues); + Assert.AreEqual(settings.FormatEnumsAsIntegers, other.FormatEnumsAsIntegers); + Assert.AreEqual(settings.TypeRegistry, other.TypeRegistry); + } + } +} diff --git a/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs b/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs index 714c78cbeb..f4dfde2435 100644 --- a/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs +++ b/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs @@ -674,6 +674,200 @@ namespace Google.Protobuf AssertWriteValue(value, "[ 1, 2, 3 ]"); } + [Test] + public void WriteValueWithIndentation_EmptyMessage() + { + var value = new TestEmptyMessage(); + + AssertWriteValue(value, "{}", JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_NestedTestAllTypes() + { + var value = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleBool = true, + SingleInt32 = 100, + SingleString = "multiple fields", + RepeatedString = { "string1", "string2" }, + }, + Child = new NestedTestAllTypes + { + Payload = new TestAllTypes + { + SingleString = "single field", + }, + }, + RepeatedChild = + { + new NestedTestAllTypes { Payload = new TestAllTypes { SingleString = "child 1", RepeatedString = { "string" } } }, + new NestedTestAllTypes { Payload = new TestAllTypes { SingleString = "child 2" } }, + }, + }; + + const string expectedJson = @" +{ + 'child': { + 'payload': { + 'singleString': 'single field' + } + }, + 'payload': { + 'singleInt32': 100, + 'singleBool': true, + 'singleString': 'multiple fields', + 'repeatedString': [ + 'string1', + 'string2' + ] + }, + 'repeatedChild': [ + { + 'payload': { + 'singleString': 'child 1', + 'repeatedString': [ + 'string' + ] + } + }, + { + 'payload': { + 'singleString': 'child 2' + } + } + ] +}"; + AssertWriteValue(value, expectedJson, JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_WellKnownTypes() + { + var value = new TestWellKnownTypes + { + StructField = new Struct + { + Fields = + { + { "string", Value.ForString("foo") }, + { "numbers", Value.ForList(Value.ForNumber(1), Value.ForNumber(2), Value.ForNumber(3)) }, + { "emptyList", Value.ForList() }, + { "emptyStruct", Value.ForStruct(new Struct()) }, + }, + }, + }; + + const string expectedJson = @" +{ + 'structField': { + 'string': 'foo', + 'numbers': [ + 1, + 2, + 3 + ], + 'emptyList': [], + 'emptyStruct': {} + } +}"; + AssertWriteValue(value, expectedJson, JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_StructSingleField() + { + var value = new Struct { Fields = { { "structField1", Value.ForString("structFieldValue1") } } }; + + const string expectedJson = @" +{ + 'structField1': 'structFieldValue1' +}"; + AssertWriteValue(value, expectedJson, JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_StructMultipleFields() + { + var value = new Struct + { + Fields = + { + { "structField1", Value.ForString("structFieldValue1") }, + { "structField2", Value.ForString("structFieldValue2") }, + { "structField3", Value.ForString("structFieldValue3") }, + }, + }; + + const string expectedJson = @" +{ + 'structField1': 'structFieldValue1', + 'structField2': 'structFieldValue2', + 'structField3': 'structFieldValue3' +}"; + AssertWriteValue(value, expectedJson, JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void FormatWithIndentation_EmbeddedMessage() + { + var value = new TestAllTypes { SingleInt32 = 100, SingleInt64 = 3210987654321L }; + var formatter = new JsonFormatter(JsonFormatter.Settings.Default.WithIndentation()); + var valueJson = formatter.Format(value, indentationLevel: 1); + + var actualJson = $@" +{{ + ""data"": {valueJson} +}}"; + const string expectedJson = @" +{ + 'data': { + 'singleInt32': 100, + 'singleInt64': '3210987654321' + } +}"; + AssertJson(expectedJson, actualJson.Trim()); + } + + [Test] + public void WriteValueWithIndentation_Map() + { + var value = new TestMap + { + MapStringString = + { + { "key1", "value1" }, + { "key2", "value2" }, + }, + }; + + const string expectedJson = @" +{ + 'mapStringString': { + 'key1': 'value1', + 'key2': 'value2' + } +}"; + + AssertWriteValue(value, expectedJson, JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_List() + { + var value = new RepeatedField { 1, 2, 3 }; + AssertWriteValue(value, "[\n 1,\n 2,\n 3\n]", JsonFormatter.Settings.Default.WithIndentation()); + } + + [Test] + public void WriteValueWithIndentation_CustomIndentation() + { + var value = new RepeatedField { 1, 2, 3 }; + AssertWriteValue(value, "[\n\t1,\n\t2,\n\t3\n]", JsonFormatter.Settings.Default.WithIndentation("\t")); + } + [Test] public void Proto2_DefaultValuesWritten() { @@ -683,7 +877,7 @@ namespace Google.Protobuf private static void AssertWriteValue(object value, string expectedJson, JsonFormatter.Settings settings = null) { - var writer = new StringWriter(); + var writer = new StringWriter { NewLine = "\n" }; new JsonFormatter(settings ?? JsonFormatter.Settings.Default).WriteValue(writer, value); string actual = writer.ToString(); AssertJson(expectedJson, actual); @@ -691,13 +885,17 @@ namespace Google.Protobuf /// /// Checks that the actual JSON is the same as the expected JSON - but after replacing - /// all apostrophes in the expected JSON with double quotes. This basically makes the tests easier - /// to read. + /// all apostrophes in the expected JSON with double quotes, trimming leading whitespace and normalizing new lines. + /// This basically makes the tests easier to read. /// + /// + /// Line endings are normalized because indented JSON strings are generated with system-specific line endings, + /// while line endings in the test cases are hard-coded, but may be converted during source checkout, depending + /// on git settings, causing unpredictability in the test results otherwise. private static void AssertJson(string expectedJsonWithApostrophes, string actualJson) { - var expectedJson = expectedJsonWithApostrophes.Replace("'", "\""); - Assert.AreEqual(expectedJson, actualJson); + var expectedJson = expectedJsonWithApostrophes.Replace("'", "\"").Replace("\r\n", "\n").TrimStart(); + Assert.AreEqual(expectedJson, actualJson.Replace("\r\n", "\n")); } } } diff --git a/csharp/src/Google.Protobuf/JsonFormatter.cs b/csharp/src/Google.Protobuf/JsonFormatter.cs index 2ef10ee7e9..4482c8790b 100644 --- a/csharp/src/Google.Protobuf/JsonFormatter.cs +++ b/csharp/src/Google.Protobuf/JsonFormatter.cs @@ -63,7 +63,12 @@ namespace Google.Protobuf internal const string AnyDiagnosticValueField = "@value"; internal const string AnyWellKnownTypeValueField = "value"; private const string NameValueSeparator = ": "; - private const string PropertySeparator = ", "; + private const string ValueSeparator = ", "; + private const string MultilineValueSeparator = ","; + private const char ObjectOpenBracket = '{'; + private const char ObjectCloseBracket = '}'; + private const char ListBracketOpen = '['; + private const char ListBracketClose = ']'; /// /// Returns a formatter using the default settings. @@ -140,11 +145,26 @@ namespace Google.Protobuf /// Formats the specified message as JSON. /// /// The message to format. + /// This method delegates to Format(IMessage, int) with indentationLevel = 0. /// The formatted message. - public string Format(IMessage message) + public string Format(IMessage message) => Format(message, indentationLevel: 0); + + /// + /// Formats the specified message as JSON. + /// + /// The message to format. + /// Indentation level to start at. + /// To keep consistent indentation when embedding a message inside another JSON string, set . E.g: + /// + /// var response = $@"{{ + /// ""data"": { Format(message, indentationLevel: 1) } + /// }}" + /// + /// The formatted message. + public string Format(IMessage message, int indentationLevel) { var writer = new StringWriter(); - Format(message, writer); + Format(message, writer, indentationLevel); return writer.ToString(); } @@ -153,19 +173,29 @@ namespace Google.Protobuf /// /// The message to format. /// The TextWriter to write the formatted message to. + /// This method delegates to Format(IMessage, TextWriter, int) with indentationLevel = 0. /// The formatted message. - public void Format(IMessage message, TextWriter writer) + public void Format(IMessage message, TextWriter writer) => Format(message, writer, indentationLevel: 0); + + /// + /// Formats the specified message as JSON. When is not null, start indenting at the specified . + /// + /// The message to format. + /// The TextWriter to write the formatted message to. + /// Indentation level to start at. + /// To keep consistent indentation when embedding a message inside another JSON string, set . + public void Format(IMessage message, TextWriter writer, int indentationLevel) { ProtoPreconditions.CheckNotNull(message, nameof(message)); ProtoPreconditions.CheckNotNull(writer, nameof(writer)); if (message.Descriptor.IsWellKnownType) { - WriteWellKnownTypeValue(writer, message.Descriptor, message); + WriteWellKnownTypeValue(writer, message.Descriptor, message, indentationLevel); } else { - WriteMessage(writer, message); + WriteMessage(writer, message, indentationLevel); } } @@ -192,7 +222,7 @@ namespace Google.Protobuf return diagnosticFormatter.Format(message); } - private void WriteMessage(TextWriter writer, IMessage message) + private void WriteMessage(TextWriter writer, IMessage message, int indentationLevel) { if (message == null) { @@ -207,12 +237,13 @@ namespace Google.Protobuf return; } } - writer.Write("{ "); - bool writtenFields = WriteMessageFields(writer, message, false); - writer.Write(writtenFields ? " }" : "}"); + + WriteBracketOpen(writer, ObjectOpenBracket); + bool writtenFields = WriteMessageFields(writer, message, false, indentationLevel + 1); + WriteBracketClose(writer, ObjectCloseBracket, writtenFields, indentationLevel); } - private bool WriteMessageFields(TextWriter writer, IMessage message, bool assumeFirstFieldWritten) + private bool WriteMessageFields(TextWriter writer, IMessage message, bool assumeFirstFieldWritten, int indentationLevel) { var fields = message.Descriptor.Fields; bool first = !assumeFirstFieldWritten; @@ -226,10 +257,8 @@ namespace Google.Protobuf continue; } - if (!first) - { - writer.Write(PropertySeparator); - } + MaybeWriteValueSeparator(writer, first); + MaybeWriteValueWhitespace(writer, indentationLevel); if (settings.PreserveProtoFieldNames) { @@ -240,13 +269,23 @@ namespace Google.Protobuf WriteString(writer, accessor.Descriptor.JsonName); } writer.Write(NameValueSeparator); - WriteValue(writer, value); + WriteValue(writer, value, indentationLevel); first = false; } return !first; } + private void MaybeWriteValueSeparator(TextWriter writer, bool first) + { + if (first) + { + return; + } + + writer.Write(settings.Indentation == null ? ValueSeparator : MultilineValueSeparator); + } + /// /// Determines whether or not a field value should be serialized according to the field, /// its value in the message, and the settings of this formatter. @@ -342,7 +381,19 @@ namespace Google.Protobuf /// /// The writer to write the value to. Must not be null. /// The value to write. May be null. - public void WriteValue(TextWriter writer, object value) + /// Delegates to WriteValue(TextWriter, object, int) with indentationLevel = 0. + public void WriteValue(TextWriter writer, object value) => WriteValue(writer, value, 0); + + /// + /// Writes a single value to the given writer as JSON. Only types understood by + /// Protocol Buffers can be written in this way. This method is only exposed for + /// advanced use cases; most users should be using + /// or . + /// + /// The writer to write the value to. Must not be null. + /// The value to write. May be null. + /// The current indentationLevel. Not used when is null. + public void WriteValue(TextWriter writer, object value, int indentationLevel) { if (value == null || value is NullValue) { @@ -365,11 +416,11 @@ namespace Google.Protobuf } else if (value is IDictionary dictionary) { - WriteDictionary(writer, dictionary); + WriteDictionary(writer, dictionary, indentationLevel); } else if (value is IList list) { - WriteList(writer, list); + WriteList(writer, list, indentationLevel); } else if (value is int || value is uint) { @@ -418,7 +469,7 @@ namespace Google.Protobuf } else if (value is IMessage message) { - Format(message, writer); + Format(message, writer, indentationLevel); } else { @@ -432,7 +483,7 @@ namespace Google.Protobuf /// values are using the embedded well-known types, in order to allow for dynamic messages /// in the future. /// - private void WriteWellKnownTypeValue(TextWriter writer, MessageDescriptor descriptor, object value) + private void WriteWellKnownTypeValue(TextWriter writer, MessageDescriptor descriptor, object value, int indentationLevel) { // Currently, we can never actually get here, because null values are always handled by the caller. But if we *could*, // this would do the right thing. @@ -472,26 +523,26 @@ namespace Google.Protobuf } if (descriptor.FullName == Struct.Descriptor.FullName) { - WriteStruct(writer, (IMessage)value); + WriteStruct(writer, (IMessage)value, indentationLevel); return; } if (descriptor.FullName == ListValue.Descriptor.FullName) { var fieldAccessor = descriptor.Fields[ListValue.ValuesFieldNumber].Accessor; - WriteList(writer, (IList)fieldAccessor.GetValue((IMessage)value)); + WriteList(writer, (IList)fieldAccessor.GetValue((IMessage)value), indentationLevel); return; } if (descriptor.FullName == Value.Descriptor.FullName) { - WriteStructFieldValue(writer, (IMessage)value); + WriteStructFieldValue(writer, (IMessage)value, indentationLevel); return; } if (descriptor.FullName == Any.Descriptor.FullName) { - WriteAny(writer, (IMessage)value); + WriteAny(writer, (IMessage)value, indentationLevel); return; } - WriteMessage(writer, (IMessage)value); + WriteMessage(writer, (IMessage)value, indentationLevel); } private void WriteTimestamp(TextWriter writer, IMessage value) @@ -519,7 +570,7 @@ namespace Google.Protobuf writer.Write(FieldMask.ToJson(paths, DiagnosticOnly)); } - private void WriteAny(TextWriter writer, IMessage value) + private void WriteAny(TextWriter writer, IMessage value, int indentationLevel) { if (DiagnosticOnly) { @@ -536,23 +587,23 @@ namespace Google.Protobuf throw new InvalidOperationException($"Type registry has no descriptor for type name '{typeName}'"); } IMessage message = descriptor.Parser.ParseFrom(data); - writer.Write("{ "); + WriteBracketOpen(writer, ObjectOpenBracket); WriteString(writer, AnyTypeUrlField); writer.Write(NameValueSeparator); WriteString(writer, typeUrl); if (descriptor.IsWellKnownType) { - writer.Write(PropertySeparator); + writer.Write(ValueSeparator); WriteString(writer, AnyWellKnownTypeValueField); writer.Write(NameValueSeparator); - WriteWellKnownTypeValue(writer, descriptor, message); + WriteWellKnownTypeValue(writer, descriptor, message, indentationLevel); } else { - WriteMessageFields(writer, message, true); + WriteMessageFields(writer, message, true, indentationLevel); } - writer.Write(" }"); + WriteBracketClose(writer, ObjectCloseBracket, true, indentationLevel); } private void WriteDiagnosticOnlyAny(TextWriter writer, IMessage value) @@ -563,7 +614,7 @@ namespace Google.Protobuf WriteString(writer, AnyTypeUrlField); writer.Write(NameValueSeparator); WriteString(writer, typeUrl); - writer.Write(PropertySeparator); + writer.Write(ValueSeparator); WriteString(writer, AnyDiagnosticValueField); writer.Write(NameValueSeparator); writer.Write('"'); @@ -572,9 +623,9 @@ namespace Google.Protobuf writer.Write(" }"); } - private void WriteStruct(TextWriter writer, IMessage message) + private void WriteStruct(TextWriter writer, IMessage message, int indentationLevel) { - writer.Write("{ "); + WriteBracketOpen(writer, ObjectOpenBracket); IDictionary fields = (IDictionary) message.Descriptor.Fields[Struct.FieldsFieldNumber].Accessor.GetValue(message); bool first = true; foreach (DictionaryEntry entry in fields) @@ -586,19 +637,17 @@ namespace Google.Protobuf throw new InvalidOperationException("Struct fields cannot have an empty key or a null value."); } - if (!first) - { - writer.Write(PropertySeparator); - } + MaybeWriteValueSeparator(writer, first); + MaybeWriteValueWhitespace(writer, indentationLevel + 1); WriteString(writer, key); writer.Write(NameValueSeparator); - WriteStructFieldValue(writer, value); + WriteStructFieldValue(writer, value, indentationLevel + 1); first = false; } - writer.Write(first ? "}" : " }"); + WriteBracketClose(writer, ObjectCloseBracket, !first, indentationLevel); } - private void WriteStructFieldValue(TextWriter writer, IMessage message) + private void WriteStructFieldValue(TextWriter writer, IMessage message, int indentationLevel) { var specifiedField = message.Descriptor.Oneofs[0].Accessor.GetCaseFieldDescriptor(message); if (specifiedField == null) @@ -619,7 +668,7 @@ namespace Google.Protobuf case Value.ListValueFieldNumber: // Structs and ListValues are nested messages, and already well-known types. var nestedMessage = (IMessage) specifiedField.Accessor.GetValue(message); - WriteWellKnownTypeValue(writer, nestedMessage.Descriptor, nestedMessage); + WriteWellKnownTypeValue(writer, nestedMessage.Descriptor, nestedMessage, indentationLevel); return; case Value.NullValueFieldNumber: WriteNull(writer); @@ -629,33 +678,30 @@ namespace Google.Protobuf } } - internal void WriteList(TextWriter writer, IList list) + internal void WriteList(TextWriter writer, IList list, int indentationLevel = 0) { - writer.Write("[ "); + WriteBracketOpen(writer, ListBracketOpen); + bool first = true; foreach (var value in list) { - if (!first) - { - writer.Write(PropertySeparator); - } - WriteValue(writer, value); + MaybeWriteValueSeparator(writer, first); + MaybeWriteValueWhitespace(writer, indentationLevel + 1); + WriteValue(writer, value, indentationLevel + 1); first = false; } - writer.Write(first ? "]" : " ]"); + + WriteBracketClose(writer, ListBracketClose, !first, indentationLevel); } - internal void WriteDictionary(TextWriter writer, IDictionary dictionary) + internal void WriteDictionary(TextWriter writer, IDictionary dictionary, int indentationLevel = 0) { - writer.Write("{ "); + WriteBracketOpen(writer, ObjectOpenBracket); + bool first = true; // This will box each pair. Could use IDictionaryEnumerator, but that's ugly in terms of disposal. foreach (DictionaryEntry pair in dictionary) { - if (!first) - { - writer.Write(PropertySeparator); - } string keyText; if (pair.Key is string s) { @@ -677,12 +723,16 @@ namespace Google.Protobuf } throw new ArgumentException("Unhandled dictionary key type: " + pair.Key.GetType()); } + + MaybeWriteValueSeparator(writer, first); + MaybeWriteValueWhitespace(writer, indentationLevel + 1); WriteString(writer, keyText); writer.Write(NameValueSeparator); WriteValue(writer, pair.Value); first = false; } - writer.Write(first ? "}" : " }"); + + WriteBracketClose(writer, ObjectCloseBracket, !first, indentationLevel); } /// @@ -766,6 +816,49 @@ namespace Google.Protobuf writer.Write(Hex[(c >> 0) & 0xf]); } + private void WriteBracketOpen(TextWriter writer, char openChar) + { + writer.Write(openChar); + if (settings.Indentation == null) + { + writer.Write(' '); + } + } + + private void WriteBracketClose(TextWriter writer, char closeChar, bool hasFields, int indentationLevel) + { + if (hasFields) + { + if (settings.Indentation != null) + { + writer.WriteLine(); + WriteIndentation(writer, indentationLevel); + } + else + { + writer.Write(" "); + } + } + + writer.Write(closeChar); + } + + private void MaybeWriteValueWhitespace(TextWriter writer, int indentationLevel) + { + if (settings.Indentation != null) { + writer.WriteLine(); + WriteIndentation(writer, indentationLevel); + } + } + + private void WriteIndentation(TextWriter writer, int indentationLevel) + { + for (int i = 0; i < indentationLevel; i++) + { + writer.Write(settings.Indentation); + } + } + /// /// Settings controlling JSON formatting. /// @@ -806,6 +899,10 @@ namespace Google.Protobuf /// public bool PreserveProtoFieldNames { get; } + /// + /// Indentation string, used for formatting. Setting null disables indentation. + /// + public string Indentation { get; } /// /// Creates a new object with the specified formatting of default values @@ -833,40 +930,54 @@ namespace Google.Protobuf /// The to use when formatting messages. TypeRegistry.Empty will be used if it is null. /// true to format the enums as integers; false to format enums as enum names. /// true to preserve proto field names; false to convert them to lowerCamelCase. + /// The indentation string to use for multi-line formatting. null to disable multi-line format. private Settings(bool formatDefaultValues, TypeRegistry typeRegistry, bool formatEnumsAsIntegers, - bool preserveProtoFieldNames) + bool preserveProtoFieldNames, + string indentation = null) { FormatDefaultValues = formatDefaultValues; TypeRegistry = typeRegistry ?? TypeRegistry.Empty; FormatEnumsAsIntegers = formatEnumsAsIntegers; PreserveProtoFieldNames = preserveProtoFieldNames; + Indentation = indentation; } /// /// Creates a new object with the specified formatting of default values and the current settings. /// /// true if default values (0, empty strings etc) should be formatted; false otherwise. - public Settings WithFormatDefaultValues(bool formatDefaultValues) => new Settings(formatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames); + public Settings WithFormatDefaultValues(bool formatDefaultValues) => new Settings(formatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames, Indentation); /// /// Creates a new object with the specified type registry and the current settings. /// /// The to use when formatting messages. - public Settings WithTypeRegistry(TypeRegistry typeRegistry) => new Settings(FormatDefaultValues, typeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames); + public Settings WithTypeRegistry(TypeRegistry typeRegistry) => new Settings(FormatDefaultValues, typeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames, Indentation); /// /// Creates a new object with the specified enums formatting option and the current settings. /// /// true to format the enums as integers; false to format enums as enum names. - public Settings WithFormatEnumsAsIntegers(bool formatEnumsAsIntegers) => new Settings(FormatDefaultValues, TypeRegistry, formatEnumsAsIntegers, PreserveProtoFieldNames); + public Settings WithFormatEnumsAsIntegers(bool formatEnumsAsIntegers) => new Settings(FormatDefaultValues, TypeRegistry, formatEnumsAsIntegers, PreserveProtoFieldNames, Indentation); /// /// Creates a new object with the specified field name formatting option and the current settings. /// /// true to preserve proto field names; false to convert them to lowerCamelCase. - public Settings WithPreserveProtoFieldNames(bool preserveProtoFieldNames) => new Settings(FormatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, preserveProtoFieldNames); + public Settings WithPreserveProtoFieldNames(bool preserveProtoFieldNames) => new Settings(FormatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, preserveProtoFieldNames, Indentation); + + /// + /// Creates a new object with the specified indentation and the current settings. + /// + /// The string to output for each level of indentation (nesting). The default is two spaces per level. Use null to disable indentation entirely. + /// A non-null value for will insert additional line-breaks to the JSON output. + /// Each line will contain either a single value, or braces. The default line-break is determined by , + /// which is "\n" on Unix platforms, and "\r\n" on Windows. If seems to produce empty lines, + /// you need to pass a that uses a "\n" newline. See . + /// + public Settings WithIndentation(string indentation = " ") => new Settings(FormatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames, indentation); } // Effectively a cache of mapping from enum values to the original name as specified in the proto file,