Support indented JSON formatting in csharp
diff --git a/csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs b/csharp/src/Google.Protobuf.Test/JsonFormatterSettingsTest.cs
new file mode 100644
index 0000000..f7ea97c
--- /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 714c78c..f4dfde2 100644
--- a/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs
+++ b/csharp/src/Google.Protobuf.Test/JsonFormatterTest.cs
@@ -675,6 +675,200 @@
         }
 
         [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<int> { 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<int> { 1, 2, 3 };
+            AssertWriteValue(value, "[\n\t1,\n\t2,\n\t3\n]", JsonFormatter.Settings.Default.WithIndentation("\t"));
+        }
+
+        [Test]
         public void Proto2_DefaultValuesWritten()
         {
             var value = new ProtobufTestMessages.Proto2.TestAllTypesProto2() { FieldName13 = 0 };
@@ -683,7 +877,7 @@
 
         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 @@
 
         /// <summary>
         /// 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.
         /// </summary>
+        /// <remarks>
+        /// 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.</remarks>
         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 2ef10ee..4482c87 100644
--- a/csharp/src/Google.Protobuf/JsonFormatter.cs
+++ b/csharp/src/Google.Protobuf/JsonFormatter.cs
@@ -63,7 +63,12 @@
         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 = ']';
 
         /// <summary>
         /// Returns a formatter using the default settings.
@@ -140,11 +145,26 @@
         /// Formats the specified message as JSON.
         /// </summary>
         /// <param name="message">The message to format.</param>
+        /// <remarks>This method delegates to <c>Format(IMessage, int)</c> with <c>indentationLevel = 0</c>.</remarks>
         /// <returns>The formatted message.</returns>
-        public string Format(IMessage message)
+        public string Format(IMessage message) => Format(message, indentationLevel: 0);
+
+        /// <summary>
+        /// Formats the specified message as JSON.
+        /// </summary>
+        /// <param name="message">The message to format.</param>
+        /// <param name="indentationLevel">Indentation level to start at.</param>
+        /// <remarks>To keep consistent indentation when embedding a message inside another JSON string, set <see cref="indentationLevel"/>. E.g:
+        /// <code>
+        /// var response = $@"{{
+        ///   ""data"": { Format(message, indentationLevel: 1) }
+        /// }}"</code>
+        /// </remarks>
+        /// <returns>The formatted message.</returns>
+        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 @@
         /// </summary>
         /// <param name="message">The message to format.</param>
         /// <param name="writer">The TextWriter to write the formatted message to.</param>
+        /// <remarks>This method delegates to <c>Format(IMessage, TextWriter, int)</c> with <c>indentationLevel = 0</c>.</remarks>
         /// <returns>The formatted message.</returns>
-        public void Format(IMessage message, TextWriter writer)
+        public void Format(IMessage message, TextWriter writer) => Format(message, writer, indentationLevel: 0);
+
+        /// <summary>
+        /// Formats the specified message as JSON. When <see cref="Settings.Indentation"/> is not null, start indenting at the specified <see cref="indentationLevel"/>.
+        /// </summary>
+        /// <param name="message">The message to format.</param>
+        /// <param name="writer">The TextWriter to write the formatted message to.</param>
+        /// <param name="indentationLevel">Indentation level to start at.</param>
+        /// <remarks>To keep consistent indentation when embedding a message inside another JSON string, set <see cref="indentationLevel"/>.</remarks>
+        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 @@
             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 @@
                     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 @@
                     continue;
                 }
 
-                if (!first)
-                {
-                    writer.Write(PropertySeparator);
-                }
+                MaybeWriteValueSeparator(writer, first);
+                MaybeWriteValueWhitespace(writer, indentationLevel);
 
                 if (settings.PreserveProtoFieldNames)
                 {
@@ -240,13 +269,23 @@
                     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);
+        }
+
         /// <summary>
         /// 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 @@
         /// </summary>
         /// <param name="writer">The writer to write the value to. Must not be null.</param>
         /// <param name="value">The value to write. May be null.</param>
-        public void WriteValue(TextWriter writer, object value)
+        /// <remarks>Delegates to <c>WriteValue(TextWriter, object, int)</c> with <c>indentationLevel = 0</c>.</remarks>
+        public void WriteValue(TextWriter writer, object value) => WriteValue(writer, value, 0);
+
+        /// <summary>
+        /// 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 <see cref="Format(IMessage)"/>
+        /// or <see cref="Format(IMessage, TextWriter)"/>.
+        /// </summary>
+        /// <param name="writer">The writer to write the value to. Must not be null.</param>
+        /// <param name="value">The value to write. May be null.</param>
+        /// <param name="indentationLevel">The current indentationLevel. Not used when <see cref="Settings.Indentation"/> is null.</param>
+        public void WriteValue(TextWriter writer, object value, int indentationLevel)
         {
             if (value == null || value is NullValue)
             {
@@ -365,11 +416,11 @@
             }
             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 @@
             }
             else if (value is IMessage message)
             {
-                Format(message, writer);
+                Format(message, writer, indentationLevel);
             }
             else
             {
@@ -432,7 +483,7 @@
         /// values are using the embedded well-known types, in order to allow for dynamic messages
         /// in the future.
         /// </summary>
-        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 @@
             }
             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 @@
             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 @@
                 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 @@
             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 @@
             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 @@
                     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 @@
                 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 @@
             }
         }
 
-        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 @@
                     }
                     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);
         }
 
         /// <summary>
@@ -766,6 +816,49 @@
             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);
+            }
+        }
+
         /// <summary>
         /// Settings controlling JSON formatting.
         /// </summary>
@@ -806,6 +899,10 @@
             /// </summary>
             public bool PreserveProtoFieldNames { get; }
 
+            /// <summary>
+            /// Indentation string, used for formatting. Setting null disables indentation.
+            /// </summary>
+            public string Indentation { get; }
 
             /// <summary>
             /// Creates a new <see cref="Settings"/> object with the specified formatting of default values
@@ -833,40 +930,54 @@
             /// <param name="typeRegistry">The <see cref="TypeRegistry"/> to use when formatting <see cref="Any"/> messages. TypeRegistry.Empty will be used if it is null.</param>
             /// <param name="formatEnumsAsIntegers"><c>true</c> to format the enums as integers; <c>false</c> to format enums as enum names.</param>
             /// <param name="preserveProtoFieldNames"><c>true</c> to preserve proto field names; <c>false</c> to convert them to lowerCamelCase.</param>
+            /// <param name="indentation">The indentation string to use for multi-line formatting. <c>null</c> to disable multi-line format.</param>
             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;
             }
 
             /// <summary>
             /// Creates a new <see cref="Settings"/> object with the specified formatting of default values and the current settings.
             /// </summary>
             /// <param name="formatDefaultValues"><c>true</c> if default values (0, empty strings etc) should be formatted; <c>false</c> otherwise.</param>
-            public Settings WithFormatDefaultValues(bool formatDefaultValues) => new Settings(formatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames);
+            public Settings WithFormatDefaultValues(bool formatDefaultValues) => new Settings(formatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames, Indentation);
 
             /// <summary>
             /// Creates a new <see cref="Settings"/> object with the specified type registry and the current settings.
             /// </summary>
             /// <param name="typeRegistry">The <see cref="TypeRegistry"/> to use when formatting <see cref="Any"/> messages.</param>
-            public Settings WithTypeRegistry(TypeRegistry typeRegistry) => new Settings(FormatDefaultValues, typeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames);
+            public Settings WithTypeRegistry(TypeRegistry typeRegistry) => new Settings(FormatDefaultValues, typeRegistry, FormatEnumsAsIntegers, PreserveProtoFieldNames, Indentation);
 
             /// <summary>
             /// Creates a new <see cref="Settings"/> object with the specified enums formatting option and the current settings.
             /// </summary>
             /// <param name="formatEnumsAsIntegers"><c>true</c> to format the enums as integers; <c>false</c> to format enums as enum names.</param>
-            public Settings WithFormatEnumsAsIntegers(bool formatEnumsAsIntegers) => new Settings(FormatDefaultValues, TypeRegistry, formatEnumsAsIntegers, PreserveProtoFieldNames);
+            public Settings WithFormatEnumsAsIntegers(bool formatEnumsAsIntegers) => new Settings(FormatDefaultValues, TypeRegistry, formatEnumsAsIntegers, PreserveProtoFieldNames, Indentation);
 
             /// <summary>
             /// Creates a new <see cref="Settings"/> object with the specified field name formatting option and the current settings.
             /// </summary>
             /// <param name="preserveProtoFieldNames"><c>true</c> to preserve proto field names; <c>false</c> to convert them to lowerCamelCase.</param>
-            public Settings WithPreserveProtoFieldNames(bool preserveProtoFieldNames) => new Settings(FormatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, preserveProtoFieldNames);
+            public Settings WithPreserveProtoFieldNames(bool preserveProtoFieldNames) => new Settings(FormatDefaultValues, TypeRegistry, FormatEnumsAsIntegers, preserveProtoFieldNames, Indentation);
+
+            /// <summary>
+            /// Creates a new <see cref="Settings"/> object with the specified indentation and the current settings.
+            /// </summary>
+            /// <param name="indentation">The string to output for each level of indentation (nesting). The default is two spaces per level. Use null to disable indentation entirely.</param>
+            /// <remarks>A non-null value for <see cref="Indentation"/> 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 <see cref="Environment.NewLine"/>,
+            /// which is <c>"\n"</c> on Unix platforms, and <c>"\r\n"</c> on Windows. If <see cref="JsonFormatter"/> seems to produce empty lines,
+            /// you need to pass a <see cref="TextWriter"/> that uses a <c>"\n"</c> newline. See <see cref="JsonFormatter.Format(Google.Protobuf.IMessage, TextWriter)"/>.
+            /// </remarks>
+            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,