diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 4a7c7eb..7fe04ac 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1,2 @@ github: vonhoff +ko_fi: vonhoff \ No newline at end of file diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 02586a1..0528c8c 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,6 +1,6 @@ -**Motivation and Context** +**Context** diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6fabb83..41a3a05 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -9,29 +9,29 @@ on: jobs: build-and-test: runs-on: windows-latest - + steps: - - uses: actions/checkout@v3 - + - uses: actions/checkout@v4 + - name: Setup .NET 8.0 - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@v5 with: dotnet-version: 8.0.x - + - name: Setup MSBuild - uses: microsoft/setup-msbuild@v1.1 - + uses: microsoft/setup-msbuild@v2 + - name: Restore dependencies run: dotnet restore - + - name: Build run: dotnet build --no-restore --configuration Release --framework net8.0-windows - + - name: Test with Coverage run: dotnet test --no-build --configuration Release --framework net8.0-windows --collect:"XPlat Code Coverage" --results-directory ./coverage - name: Generate coverage report - uses: danielpalme/ReportGenerator-GitHub-Action@5.1.4 + uses: danielpalme/ReportGenerator-GitHub-Action@v5 with: reports: './coverage/**/coverage.cobertura.xml' targetdir: './coverage/report' @@ -50,7 +50,7 @@ jobs: $coverage = Select-Xml -Path "./coverage/**/coverage.cobertura.xml" -XPath "//coverage/@line-rate" | Select-Object -ExpandProperty Node | Select-Object -ExpandProperty Value $coveragePercent = [math]::Round([double]$coverage * 100, 2) Write-Host "Current line coverage: $coveragePercent%" - if ($coveragePercent -lt 75) { - Write-Error "Code coverage ($coveragePercent%) is below the required threshold of 75%" + if ($coveragePercent -lt 80) { + Write-Error "Code coverage ($coveragePercent%) is below the required threshold of 80%" exit 1 } \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 87ef7e4..832b495 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -55,7 +55,7 @@ a project may be further defined and clarified by project maintainers. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at `simon.vonhoff[at]outlook.com`. All +reported by contacting the project team at `simon.vonhoff@outlook.com`. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. diff --git a/Demo/Form1.Designer.cs b/Demo/Form1.Designer.cs index 049794b..2604634 100644 --- a/Demo/Form1.Designer.cs +++ b/Demo/Form1.Designer.cs @@ -46,15 +46,19 @@ private void InitializeComponent() this.btnStructure = new System.Windows.Forms.ToolStripButton(); this.btnComplex = new System.Windows.Forms.ToolStripButton(); this.toolStripSeparator8 = new System.Windows.Forms.ToolStripSeparator(); + this.btnClear = new System.Windows.Forms.ToolStripButton(); + this.btnRestore = new System.Windows.Forms.ToolStripButton(); + this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); this.btnDispose = new System.Windows.Forms.ToolStripButton(); this.btnReset = new System.Windows.Forms.ToolStripButton(); this.toolStripSeparator6 = new System.Windows.Forms.ToolStripSeparator(); this.btnAutoScroll = new System.Windows.Forms.ToolStripButton(); + this.btnPrettyPrint = new System.Windows.Forms.ToolStripButton(); + this.toolStripLabelIndent = new System.Windows.Forms.ToolStripLabel(); + this.numericUpDownSpacesPerIndent = new System.Windows.Forms.NumericUpDown(); + this.toolStripControlHostIndent = new System.Windows.Forms.ToolStripControlHost(this.numericUpDownSpacesPerIndent); this.panel1 = new System.Windows.Forms.Panel(); this.richTextBox1 = new System.Windows.Forms.RichTextBox(); - this.btnRestore = new System.Windows.Forms.ToolStripButton(); - this.btnClear = new System.Windows.Forms.ToolStripButton(); - this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator(); this.toolStrip1.SuspendLayout(); this.toolStrip2.SuspendLayout(); this.panel1.SuspendLayout(); @@ -174,7 +178,10 @@ private void InitializeComponent() this.btnDispose, this.btnReset, this.toolStripSeparator6, - this.btnAutoScroll}); + this.btnAutoScroll, + this.btnPrettyPrint, + this.toolStripLabelIndent, + this.toolStripControlHostIndent}); this.toolStrip2.Location = new System.Drawing.Point(0, 25); this.toolStrip2.Name = "toolStrip2"; this.toolStrip2.Padding = new System.Windows.Forms.Padding(3, 0, 3, 0); @@ -269,6 +276,48 @@ private void InitializeComponent() this.btnAutoScroll.ToolTipText = "Toggle auto-scroll behavior"; this.btnAutoScroll.Click += new System.EventHandler(this.btnAutoScroll_Click); // + // btnPrettyPrint + // + this.btnPrettyPrint.DisplayStyle = System.Windows.Forms.ToolStripItemDisplayStyle.Text; + this.btnPrettyPrint.Name = "btnPrettyPrint"; + this.btnPrettyPrint.Size = new System.Drawing.Size(100, 22); + this.btnPrettyPrint.Text = "Enable Pretty Print"; + this.btnPrettyPrint.ToolTipText = "Toggle pretty-printed JSON formatting"; + this.btnPrettyPrint.Click += new System.EventHandler(this.btnPrettyPrint_Click); + // + // toolStripLabelIndent + // + this.toolStripLabelIndent.Name = "toolStripLabelIndent"; + this.toolStripLabelIndent.Size = new System.Drawing.Size(38, 22); + this.toolStripLabelIndent.Text = "Indent:"; + // + // numericUpDownSpacesPerIndent + // + this.numericUpDownSpacesPerIndent.Minimum = new decimal(new int[] { + 0, + 0, + 0, + 0}); + this.numericUpDownSpacesPerIndent.Maximum = new decimal(new int[] { + 16, + 0, + 0, + 0}); + this.numericUpDownSpacesPerIndent.Value = new decimal(new int[] { + 2, + 0, + 0, + 0}); + this.numericUpDownSpacesPerIndent.Width = 50; + this.numericUpDownSpacesPerIndent.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle; + this.numericUpDownSpacesPerIndent.ValueChanged += new System.EventHandler(this.numericUpDownSpacesPerIndent_ValueChanged); + // + // toolStripControlHostIndent + // + this.toolStripControlHostIndent.Name = "toolStripControlHostIndent"; + this.toolStripControlHostIndent.Size = new System.Drawing.Size(50, 22); + this.toolStripControlHostIndent.ToolTipText = "Number of spaces per indentation level (0-16)"; + // // panel1 // this.panel1.Controls.Add(this.richTextBox1); @@ -363,6 +412,10 @@ private void InitializeComponent() private System.Windows.Forms.ToolStripButton btnReset; private System.Windows.Forms.ToolStripSeparator toolStripSeparator6; private System.Windows.Forms.ToolStripButton btnAutoScroll; + private System.Windows.Forms.ToolStripButton btnPrettyPrint; + private System.Windows.Forms.ToolStripLabel toolStripLabelIndent; + private System.Windows.Forms.NumericUpDown numericUpDownSpacesPerIndent; + private System.Windows.Forms.ToolStripControlHost toolStripControlHostIndent; private System.Windows.Forms.ToolStripButton btnRestore; private System.Windows.Forms.ToolStripButton btnClear; private System.Windows.Forms.ToolStripSeparator toolStripSeparator2; diff --git a/Demo/Form1.cs b/Demo/Form1.cs index a0a9574..f10bb41 100644 --- a/Demo/Form1.cs +++ b/Demo/Form1.cs @@ -35,6 +35,9 @@ public partial class Form1 : Form private RichTextBoxSinkOptions? _options; private RichTextBoxSink? _sink; private bool _toolbarsVisible = true; + private bool _prettyPrintJson = false; + private int _spacesPerIndent = 2; + private bool _updatingIndent = false; public Form1() { @@ -47,7 +50,9 @@ private void Initialize() _options = new RichTextBoxSinkOptions( theme: ThemePresets.Literate, outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", - formatProvider: new CultureInfo("en-US")); + formatProvider: new CultureInfo("en-US"), + prettyPrintJson: _prettyPrintJson, + spacesPerIndent: _spacesPerIndent); _sink = new RichTextBoxSink(richTextBox1, _options); Log.Logger = new LoggerConfiguration() @@ -77,6 +82,15 @@ private void Initialize() Log.Debug("Started logger."); btnDispose.Enabled = true; + btnPrettyPrint.Text = _prettyPrintJson ? "Disable Pretty Print" : "Enable Pretty Print"; + + // Update the numeric up/down control without triggering events + _updatingIndent = true; + if (numericUpDownSpacesPerIndent.Value != _spacesPerIndent) + { + numericUpDownSpacesPerIndent.Value = _spacesPerIndent; + } + _updatingIndent = false; } private void Form1_Load(object sender, EventArgs e) @@ -350,6 +364,41 @@ private void btnAutoScroll_Click(object sender, EventArgs e) btnAutoScroll.Text = _options.AutoScroll ? "Disable Auto Scroll" : "Enable Auto Scroll"; } + private void btnPrettyPrint_Click(object sender, EventArgs e) + { + _prettyPrintJson = !_prettyPrintJson; + btnPrettyPrint.Text = _prettyPrintJson ? "Disable Pretty Print" : "Enable Pretty Print"; + + // Recreate the sink and logger with new pretty print setting + CloseAndFlush(); + Initialize(); + + Log.Information("Pretty print JSON: {PrettyPrint}", _prettyPrintJson); + } + + private void numericUpDownSpacesPerIndent_ValueChanged(object sender, EventArgs e) + { + // Prevent recursive calls when we're updating the value programmatically + if (_updatingIndent) + { + return; + } + + var newValue = (int)numericUpDownSpacesPerIndent.Value; + if (newValue == _spacesPerIndent) + { + return; + } + + _spacesPerIndent = newValue; + + // Recreate the sink and logger with new indent size + CloseAndFlush(); + Initialize(); + + Log.Information("Spaces per indent changed to: {SpacesPerIndent}", _spacesPerIndent); + } + private void Form1_KeyDown(object sender, KeyEventArgs e) { if (e.Control && e.KeyCode == Keys.T) diff --git a/README.md b/README.md index 8067908..02dc429 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,10 @@ A [Serilog](https://github.com/serilog/serilog) sink that writes log events to a - Multiple theme presets with customization options - High-performance asynchronous processing - Line limit to control memory usage +- Support for pretty-printing of JSON objects - WCAG compliant color schemes based on the [Serilog WPF RichTextBox](https://github.com/serilog-contrib/serilog-sinks-richtextbox) sink. -## Get Started +## Getting Started Install the package from NuGet: @@ -52,38 +53,47 @@ Log.Information("Hello, world!"); See the [Extension Method](Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs) for more configuration options. +## Configuration Options + +| Option | Description | Default Value | +|-------------------|------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------| +| `theme` | The color theme applied when rendering individual message tokens. | `ThemePresets.Literate` | +| `autoScroll` | When `true` (default) the target control scrolls automatically to the most recent log line. | `true` | +| `maxLogLines` | Maximum number of log events retained in the in-memory circular buffer and rendered in the control. | `256` | +| `outputTemplate` | Serilog output template that controls textual formatting of each log event. | `[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}` | +| `formatProvider` | Optional culture-specific or custom formatting provider used when rendering scalar values; `null` for the invariant culture. | `CultureInfo.InvariantCulture` | +| `prettyPrintJson` | When `true`, formats JSON values with indentation and line breaks for better readability. | `false` | +| `spacesPerIndent` | Number of spaces per indentation level when pretty printing JSON. | `2` | + ## Themes Available built-in themes: -| Theme | Description | -|-----------------------------|------------------------------------------------------------------------------| -| `ThemePresets.Literate` | Styled to replicate the default theme of Serilog.Sinks.Console __(default)__ | -| `ThemePresets.Grayscale` | A theme using only shades of gray, white, and black | -| `ThemePresets.Colored` | A theme based on the original Serilog.Sinks.ColoredConsole sink | -| `ThemePresets.Luminous` | A light theme with high contrast for accessibility | +| Theme | Description | +|--------------------------|------------------------------------------------------------------------------| +| `ThemePresets.Literate` | Styled to replicate the default theme of Serilog.Sinks.Console __(default)__ | +| `ThemePresets.Grayscale` | A theme using only shades of gray, white, and black | +| `ThemePresets.Colored` | A theme based on the original Serilog.Sinks.ColoredConsole sink | +| `ThemePresets.Luminous` | A light theme with high contrast for accessibility | The themes based on the original sinks are slightly adjusted to be [WCAG compliant](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum), ensuring that the contrast ratio between text and background colors is at least 4.5:1. You can create your own custom themes by creating a new instance of the [Theme](Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Themes/Theme.cs) class and passing it to the `RichTextBox` extension method. Look at the [existing themes](Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Themes/ThemePresets.cs) for examples. -## Frequently Asked Questions - -### Why is the package name so long? +## Support the Project 💖 -Shorter alternatives were already reserved in the NuGet registry, so a more descriptive name was needed for this implementation. The name is a bit long, but it makes it easier to find the package in the NuGet registry. +This project has been maintained since 2022 and is still under active development. If you find it useful, please consider supporting it. Your support will help keep the project alive and allow me to dedicate more time to making improvements. You can support it through: -### Why use a WinForms RichTextBox instead of a WPF RichTextBox? +* [GitHub Sponsors](https://github.com/sponsors/vonhoff) +* [Ko-fi](https://ko-fi.com/vonhoff) -This sink is specifically designed for WinForms applications to avoid the WPF framework. Using a WPF-based logging component would require adding the entire WPF framework with all its dependencies, greatly increasing the size of the application. +Every contribution of any size helps sustain ongoing development. -## Support and Contribute +## Contributing -If you find value in this project, there are several ways you can contribute: +Contributions are welcome! Report issues, improve documentation, or submit pull requests. -- Give the [project](https://github.com/vonhoff/Serilog.Sinks.RichTextBox.WinForms.Colored) a star on GitHub ⭐ -- Support the project through [GitHub Sponsors](https://github.com/sponsors/vonhoff) -- Improve docs, report bugs, or submit PRs (see [CONTRIBUTING.md](CONTRIBUTING.md)) +See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines. ## License diff --git a/SECURITY.md b/SECURITY.md index bebcfc1..0ccb2ef 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Reporting a Vulnerability -If you find a security issue, please email `simon.vonhoff[at]outlook.com` with a description. Include a suggested fix if you have one. +If you find a security issue, please email `simon.vonhoff@outlook.com` with a description. Include a suggested fix if you have one. We will review the report, release a fix or mitigation if needed, and credit you. Do **not** disclose the issue publicly until a fix is released. Once addressed, public disclosure is allowed. diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Collections/ConcurrentCircularBufferTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Collections/ConcurrentCircularBufferTests.cs index 85da98c..10ece79 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Collections/ConcurrentCircularBufferTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Collections/ConcurrentCircularBufferTests.cs @@ -8,47 +8,40 @@ public class ConcurrentCircularBufferTests [Fact] public void TakeSnapshot_WithItemsLessThanCapacity_ReturnsAllItemsInOrder() { - // Arrange var buffer = new ConcurrentCircularBuffer(3); buffer.Add(1); buffer.Add(2); - // Act var snapshot = new List(); buffer.TakeSnapshot(snapshot); - // Assert Assert.Equal(new[] { 1, 2 }, snapshot); } [Fact] public void AddBeyondCapacity_OverwritesOldestItemsAndMaintainsOrder() { - // Arrange var buffer = new ConcurrentCircularBuffer(3); buffer.Add(1); buffer.Add(2); buffer.Add(3); buffer.Add(4); // Should overwrite the oldest item (1) - // Act var snapshot = new List(); buffer.TakeSnapshot(snapshot); - // Assert Assert.Equal(new[] { 2, 3, 4 }, snapshot); } [Fact] public void Clear_FollowedByAdds_SnapshotContainsOnlyNewItems() { - // Arrange var buffer = new ConcurrentCircularBuffer(3); buffer.Add(1); buffer.Add(2); buffer.Add(3); - // Act & Assert - After clear, snapshot should be empty + // After clear, snapshot should be empty buffer.Clear(); var snapshotAfterClear = new List(); buffer.TakeSnapshot(snapshotAfterClear); @@ -71,7 +64,6 @@ public void Clear_FollowedByAdds_SnapshotContainsOnlyNewItems() [Fact] public void Restore_AfterClear_ReturnsAllItemsAgain() { - // Arrange var buffer = new ConcurrentCircularBuffer(3); buffer.Add(1); buffer.Add(2); @@ -82,12 +74,11 @@ public void Restore_AfterClear_ReturnsAllItemsAgain() buffer.Add(4); buffer.Add(5); - // Act - Restore should make all items visible again + // Restore should make all items visible again buffer.Restore(); var snapshot = new List(); buffer.TakeSnapshot(snapshot); - // Assert Assert.Equal(new[] { 3, 4, 5 }, snapshot); } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/BasicFormattingTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/BasicFormattingTests.cs index 092ad9c..1b68499 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/BasicFormattingTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/BasicFormattingTests.cs @@ -17,7 +17,6 @@ public void Constructor_InitializesRichTextBoxCorrectly() [Fact] public void Emit_WithScalarValues_FormatsCorrectly() { - // Arrange var template = _parser.Parse("String value: {String}"); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -26,10 +25,8 @@ public void Emit_WithScalarValues_FormatsCorrectly() template, new[] { new LogEventProperty("String", new ScalarValue("test")) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("String value: test", text); } @@ -37,7 +34,6 @@ public void Emit_WithScalarValues_FormatsCorrectly() [Fact] public void Emit_WithDictionaryValue_FormatsCorrectly() { - // Arrange var dict = new Dictionary { ["key1"] = "value1", @@ -59,10 +55,8 @@ public void Emit_WithDictionaryValue_FormatsCorrectly() template, new[] { new LogEventProperty("Dict", dictValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Dictionary: {", text); Assert.Contains("[key1]=value1", text); @@ -72,7 +66,6 @@ public void Emit_WithDictionaryValue_FormatsCorrectly() [Fact] public void Emit_WithSequenceValue_FormatsCorrectly() { - // Arrange var array = new object[] { 1, 2, 3, "test" }; var sequenceValue = new SequenceValue(array.Select(x => new ScalarValue(x))); @@ -84,10 +77,8 @@ public void Emit_WithSequenceValue_FormatsCorrectly() template, new[] { new LogEventProperty("Array", sequenceValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Array: [", text); Assert.Contains("1, 2, 3, test", text); @@ -96,7 +87,6 @@ public void Emit_WithSequenceValue_FormatsCorrectly() [Fact] public void Emit_WithStructureValue_FormatsCorrectly() { - // Arrange var structureValue = new StructureValue(new[] { new LogEventProperty("Name", new ScalarValue("Test")), @@ -112,10 +102,8 @@ public void Emit_WithStructureValue_FormatsCorrectly() template, new[] { new LogEventProperty("Object", structureValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Object: {", text); Assert.Contains("Name=Test", text); @@ -126,7 +114,6 @@ public void Emit_WithStructureValue_FormatsCorrectly() [Fact] public void Emit_WithComplexNestedValue_FormatsCorrectly() { - // Arrange var complex = new StructureValue(new[] { new LogEventProperty("Name", new ScalarValue("Test")), @@ -146,10 +133,8 @@ public void Emit_WithComplexNestedValue_FormatsCorrectly() template, new[] { new LogEventProperty("Complex", complex) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Complex: {", text); Assert.Contains("Name=Test", text); @@ -162,7 +147,6 @@ public void Emit_WithComplexNestedValue_FormatsCorrectly() [Fact] public void Emit_WithSequenceValue_JsonFormatting_FormatsCorrectly() { - // Arrange var array = new object[] { 1, 2, 3, "test" }; var sequenceValue = new SequenceValue(array.Select(x => new ScalarValue(x))); @@ -174,10 +158,8 @@ public void Emit_WithSequenceValue_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("Array", sequenceValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Array: [", text); Assert.Contains("1, 2, 3, \"test\"", text); @@ -186,7 +168,6 @@ public void Emit_WithSequenceValue_JsonFormatting_FormatsCorrectly() [Fact] public void Emit_WithNestedSequenceValue_JsonFormatting_FormatsCorrectly() { - // Arrange var nestedArray = new object[] { new object[] { 1, 2 }, new object[] { 3, 4 } }; var sequenceValue = new SequenceValue(nestedArray.Select(x => new SequenceValue(((object[])x).Select(y => new ScalarValue(y))))); @@ -199,10 +180,8 @@ public void Emit_WithNestedSequenceValue_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("NestedArray", sequenceValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("NestedArray: [", text); Assert.Contains("[[1, 2], [3, 4]]", text); @@ -211,7 +190,6 @@ public void Emit_WithNestedSequenceValue_JsonFormatting_FormatsCorrectly() [Fact] public void Emit_WithEmptySequenceValue_JsonFormatting_FormatsCorrectly() { - // Arrange var emptyArray = new object[] { }; var sequenceValue = new SequenceValue(emptyArray.Select(x => new ScalarValue(x))); @@ -223,10 +201,8 @@ public void Emit_WithEmptySequenceValue_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("EmptyArray", sequenceValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("EmptyArray: []", text); } @@ -234,7 +210,6 @@ public void Emit_WithEmptySequenceValue_JsonFormatting_FormatsCorrectly() [Fact] public void Emit_WithStructureValue_JsonFormatting_FormatsCorrectly() { - // Arrange var structureValue = new StructureValue(new[] { new LogEventProperty("Name", new ScalarValue("Test")), @@ -250,10 +225,8 @@ public void Emit_WithStructureValue_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("Object", structureValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Object: {", text); Assert.Contains("\"Name\": \"Test\"", text); @@ -264,7 +237,6 @@ public void Emit_WithStructureValue_JsonFormatting_FormatsCorrectly() [Fact] public void Emit_WithStructureValueWithTypeTag_JsonFormatting_FormatsCorrectly() { - // Arrange var structureValue = new StructureValue( new[] { @@ -281,10 +253,8 @@ public void Emit_WithStructureValueWithTypeTag_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("Object", structureValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Object: {", text); Assert.Contains("\"Name\": \"Test\"", text); @@ -295,9 +265,7 @@ public void Emit_WithStructureValueWithTypeTag_JsonFormatting_FormatsCorrectly() [Fact] public void Emit_WithEmptyStructureValue_JsonFormatting_FormatsCorrectly() { - // Arrange var structureValue = new StructureValue(Array.Empty()); - var template = _parser.Parse("Object: {@Object:j}"); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -306,10 +274,8 @@ public void Emit_WithEmptyStructureValue_JsonFormatting_FormatsCorrectly() template, new[] { new LogEventProperty("Object", structureValue) }); - // Act _renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Object: {}", text); } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/JsonFormattingTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/JsonFormattingTests.cs index 6b3b257..bbb7f0c 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/JsonFormattingTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/JsonFormattingTests.cs @@ -1,4 +1,5 @@ using Serilog.Events; +using Serilog.Sinks.RichTextBoxForms; using Xunit; namespace Serilog.Tests.Integration @@ -92,5 +93,205 @@ public void ControlCharacters_AreEscapedAsUnicode() Assert.Equal(expected, RenderAndGetText(strEvent, "{Message:j}")); } } + + [Fact] + public void PrettyPrintJson_FormatsNestedObjectsWithIndentation() + { + var nestedProp = new LogEventProperty("Nested", new StructureValue(new[] + { + new LogEventProperty("Id", new ScalarValue(123)), + new LogEventProperty("Name", new ScalarValue("test")) + }, "MyObj")); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Nested:j}"), new[] { nestedProp }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + var expected = "{\n \"Id\": 123,\n \"Name\": \"test\",\n \"$type\": \"MyObj\"\n}"; + Assert.Equal(expected, result); + } + + [Fact] + public void PrettyPrintJson_FormatsArraysWithIndentation() + { + var arrayProp = new LogEventProperty("Array", new SequenceValue(new[] + { + new ScalarValue(1), + new ScalarValue(2), + new ScalarValue(3) + })); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Array:j}"), new[] { arrayProp }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + var expected = "[\n 1,\n 2,\n 3\n]"; + Assert.Equal(expected, result); + } + + [Fact] + public void PrettyPrintJson_FormatsDictionariesWithIndentation() + { + var dict = new Dictionary + { + { new ScalarValue("a"), new ScalarValue(1) }, + { new ScalarValue("b"), new ScalarValue("hello") } + }; + var dictProp = new LogEventProperty("DictProp", new DictionaryValue(dict)); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{DictProp:j}"), new[] { dictProp }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + var expected = "{\n \"a\": 1,\n \"b\": \"hello\"\n}"; + Assert.Equal(expected, result); + } + + [Fact] + public void PrettyPrintJson_FormatsNestedStructures() + { + var inner = new StructureValue(new[] + { + new LogEventProperty("Value", new ScalarValue(42)) + }, "Inner"); + var outer = new LogEventProperty("Outer", new StructureValue(new[] + { + new LogEventProperty("Inner", inner), + new LogEventProperty("Name", new ScalarValue("test")) + }, "Outer")); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Outer:j}"), new[] { outer }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + var expected = "{\n \"Inner\": {\n \"Value\": 42,\n \"$type\": \"Inner\"\n },\n \"Name\": \"test\",\n \"$type\": \"Outer\"\n}"; + Assert.Equal(expected, result); + } + + [Fact] + public void PrettyPrintJson_EmptyCollectionsFormatCorrectly() + { + var emptyArray = new LogEventProperty("EmptyArray", new SequenceValue(Array.Empty())); + var emptyDict = new LogEventProperty("EmptyDict", new DictionaryValue(new Dictionary())); + var emptyObject = new LogEventProperty("EmptyObject", new StructureValue(Array.Empty())); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("Array: {EmptyArray:j}, Dict: {EmptyDict:j}, Object: {EmptyObject:j}"), new[] { emptyArray, emptyDict, emptyObject }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + Assert.Contains("Array: []", result); + Assert.Contains("Dict: {}", result); + Assert.Contains("Object: {}", result); + } + + + [Fact] + public void PrettyPrintJson_RespectsSpacesPerIndent() + { + var prop = new LogEventProperty("Test", new StructureValue(new[] + { + new LogEventProperty("Id", new ScalarValue(123)) + }, "MyObj")); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Test:j}"), new[] { prop }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 2); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + var expected = "{\n \"Id\": 123,\n \"$type\": \"MyObj\"\n}"; + Assert.Equal(expected, result); + } + + + [Fact] + public void CompactJson_StillWorksByDefault() + { + var complexProp = new LogEventProperty("ComplexProp", new StructureValue(new[] { new LogEventProperty("Id", new ScalarValue(123)) }, "MyObj")); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("Value: {ComplexProp}"), new[] { complexProp }); + Assert.Equal("Value: {\"Id\": 123, \"$type\": \"MyObj\"}", RenderAndGetText(logEvent, "{Message:j}")); + } + + [Fact] + public void ScalarValue_IFormattableButNotNumericValueType() + { + var enumValue = System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance; + var prop = new LogEventProperty("EnumProp", new ScalarValue(enumValue)); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{EnumProp:j}"), new[] { prop }); + + var result = RenderAndGetText(logEvent, "{Message:j}"); + Assert.NotNull(result); + Assert.NotEmpty(result); + } + + [Fact] + public void ScalarValue_NonFormattableObject() + { + var plainObject = new object(); + var prop = new LogEventProperty("ObjectProp", new ScalarValue(plainObject)); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{ObjectProp:j}"), new[] { prop }); + + var result = RenderAndGetText(logEvent, "{Message:j}"); + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.StartsWith("\"", result); + Assert.EndsWith("\"", result); + } + + [Fact] + public void PrettyPrintJson_DictionaryWithNullKey() + { + var dict = new Dictionary + { + { new ScalarValue(null), new ScalarValue("value") } + }; + var dictProp = new LogEventProperty("DictProp", new DictionaryValue(dict)); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{DictProp:j}"), new[] { dictProp }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + Assert.Contains("\"null\"", result); + Assert.Contains("\"value\"", result); + } + + [Fact] + public void PrettyPrintJson_DictionaryWithNonStringKey() + { + var dict = new Dictionary + { + { new ScalarValue(123), new ScalarValue("value") } + }; + var dictProp = new LogEventProperty("DictProp", new DictionaryValue(dict)); + var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{DictProp:j}"), new[] { dictProp }); + + var options = new RichTextBoxSinkOptions( + theme: _defaultTheme, + prettyPrintJson: true, + spacesPerIndent: 4); + + var result = RenderAndGetText(logEvent, "{Message:l}", options); + Assert.Contains("\"123\"", result); + Assert.Contains("\"value\"", result); + } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/PropertiesTokenRendererTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/PropertiesTokenRendererTests.cs index 8a3d2a2..5b68e87 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/PropertiesTokenRendererTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/PropertiesTokenRendererTests.cs @@ -1,5 +1,6 @@ using Serilog.Events; using Serilog.Parsing; +using Serilog.Sinks.RichTextBoxForms; using Serilog.Sinks.RichTextBoxForms.Rendering; using Xunit; @@ -10,11 +11,11 @@ public class PropertiesTokenRendererTests : RichTextBoxSinkTestBase [Fact] public void Render_WithAdditionalProperties_FormatsCorrectly() { - // Arrange var template = _parser.Parse("Message: {Message}"); var outputTemplate = _parser.Parse("Message: {Message} {Properties}"); var token = outputTemplate.Tokens.OfType().Single(t => t.PropertyName == "Properties"); - var renderer = new PropertiesTokenRenderer(_defaultTheme, token, outputTemplate, null); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new PropertiesTokenRenderer(token, outputTemplate, options); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -27,10 +28,8 @@ public void Render_WithAdditionalProperties_FormatsCorrectly() new LogEventProperty("Additional", new ScalarValue("value")) }); - // Act renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Additional=\"value\"", text); Assert.DoesNotContain("Message=\"test\"", text); @@ -39,11 +38,11 @@ public void Render_WithAdditionalProperties_FormatsCorrectly() [Fact] public void Render_WithJsonFormatting_FormatsCorrectly() { - // Arrange var template = _parser.Parse("Message: {Message}"); var outputTemplate = _parser.Parse("Message: {Message} {Properties:j}"); var token = outputTemplate.Tokens.OfType().Single(t => t.PropertyName == "Properties"); - var renderer = new PropertiesTokenRenderer(_defaultTheme, token, outputTemplate, null); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new PropertiesTokenRenderer(token, outputTemplate, options); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -57,10 +56,8 @@ public void Render_WithJsonFormatting_FormatsCorrectly() new LogEventProperty("Boolean", new ScalarValue(true)) }); - // Act renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("\"Number\": 42", text); Assert.Contains("\"Boolean\": true", text); @@ -70,11 +67,11 @@ public void Render_WithJsonFormatting_FormatsCorrectly() [Fact] public void Render_WithNestedProperties_FormatsCorrectly() { - // Arrange var template = _parser.Parse("Message: {Message}"); var outputTemplate = _parser.Parse("Message: {Message} {Properties}"); var token = outputTemplate.Tokens.OfType().Single(t => t.PropertyName == "Properties"); - var renderer = new PropertiesTokenRenderer(_defaultTheme, token, outputTemplate, null); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new PropertiesTokenRenderer(token, outputTemplate, options); var nestedStructure = new StructureValue(new[] { @@ -92,10 +89,8 @@ public void Render_WithNestedProperties_FormatsCorrectly() new LogEventProperty("Nested", nestedStructure) }); - // Act renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Nested={", text); Assert.Contains("NestedValue=\"nested\"", text); @@ -104,11 +99,11 @@ public void Render_WithNestedProperties_FormatsCorrectly() [Fact] public void Render_WithNoAdditionalProperties_FormatsCorrectly() { - // Arrange var template = _parser.Parse("Message: {Message}"); var outputTemplate = _parser.Parse("Message: {Message} {Properties}"); var token = outputTemplate.Tokens.OfType().Single(t => t.PropertyName == "Properties"); - var renderer = new PropertiesTokenRenderer(_defaultTheme, token, outputTemplate, null); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new PropertiesTokenRenderer(token, outputTemplate, options); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -120,10 +115,8 @@ public void Render_WithNoAdditionalProperties_FormatsCorrectly() new LogEventProperty("Message", new ScalarValue("test")) }); - // Act renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Equal("{}", text); } @@ -131,11 +124,11 @@ public void Render_WithNoAdditionalProperties_FormatsCorrectly() [Fact] public void Render_WithPropertiesInOutputTemplate_ExcludesThem() { - // Arrange var template = _parser.Parse("Message: {Message}"); var outputTemplate = _parser.Parse("Message: {Message} {Properties} {Custom}"); var token = outputTemplate.Tokens.OfType().Single(t => t.PropertyName == "Properties"); - var renderer = new PropertiesTokenRenderer(_defaultTheme, token, outputTemplate, null); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new PropertiesTokenRenderer(token, outputTemplate, options); var logEvent = new LogEvent( DateTimeOffset.Now, @@ -149,10 +142,8 @@ public void Render_WithPropertiesInOutputTemplate_ExcludesThem() new LogEventProperty("Additional", new ScalarValue("extra")) }); - // Act renderer.Render(logEvent, _canvas); - // Assert var text = _richTextBox.Text; Assert.Contains("Additional=\"extra\"", text); Assert.DoesNotContain("Custom=\"value\"", text); diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SinkLifecycleTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SinkLifecycleTests.cs index 82b8035..ac4d546 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SinkLifecycleTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SinkLifecycleTests.cs @@ -22,8 +22,8 @@ public void Dispose_CancelsMessageProcessing() try { - // Act & Assert testSink.Dispose(); + // Give the background thread time to complete Thread.Sleep(100); Assert.Throws(() => testSink.Emit(new LogEvent( diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SpecialCaseFormattingTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SpecialCaseFormattingTests.cs index 12e1dbc..007f162 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SpecialCaseFormattingTests.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/SpecialCaseFormattingTests.cs @@ -16,7 +16,6 @@ public class SpecialCaseFormattingTests : RichTextBoxSinkTestBase [InlineData("Link", "http://example.com/path", "{Link}", "{Message}", "http://example.com/path")] public void ScalarTypes_SpecialCases_DefaultFormatting_RendersCorrectly(string propertyName, object value, string template, string outputTemplate, string expected) { - // Arrange var scalarValue = value switch { byte[] bytes => new ScalarValue(bytes), @@ -31,98 +30,82 @@ public void ScalarTypes_SpecialCases_DefaultFormatting_RendersCorrectly(string p var prop = new LogEventProperty(propertyName, scalarValue); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse(template), new[] { prop }); - // Act & Assert Assert.Equal(expected, RenderAndGetText(logEvent, outputTemplate, CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_ByteArray_RendersCorrectly() { - // Arrange var bytes = new byte[] { 1, 2, 3, 4 }; var byteProp = new LogEventProperty("Bytes", new ScalarValue(bytes)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Bytes}"), new[] { byteProp }); - // Act & Assert Assert.Equal($"\"{Convert.ToBase64String(bytes)}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_DateTime_RendersCorrectly() { - // Arrange var dt = new DateTime(2023, 1, 15, 10, 30, 45, DateTimeKind.Utc); var dtProp = new LogEventProperty("Time", new ScalarValue(dt)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Time}"), new[] { dtProp }); - // Act & Assert Assert.Equal($"\"{dt.ToString("O")}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_DateTimeOffset_RendersCorrectly() { - // Arrange var dto = new DateTimeOffset(2023, 1, 15, 10, 30, 45, TimeSpan.FromHours(2)); var dtoProp = new LogEventProperty("TimeOffset", new ScalarValue(dto)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{TimeOffset}"), new[] { dtoProp }); - // Act & Assert Assert.Equal($"\"{dto.ToString("O")}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_TimeSpan_RendersCorrectly() { - // Arrange var ts = TimeSpan.FromSeconds(12345); var tsProp = new LogEventProperty("Duration", new ScalarValue(ts)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Duration}"), new[] { tsProp }); - // Act & Assert Assert.Equal($"\"{ts.ToString()}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_Guid_RendersCorrectly() { - // Arrange var guid = Guid.NewGuid(); var guidProp = new LogEventProperty("Id", new ScalarValue(guid)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Id}"), new[] { guidProp }); - // Act & Assert Assert.Equal($"\"{guid.ToString("D")}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_Uri_RendersCorrectly() { - // Arrange var uri = new Uri("http://test.com/path"); var uriProp = new LogEventProperty("Link", new ScalarValue(uri)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Link}"), new[] { uriProp }); - // Act & Assert Assert.Equal($"\"{uri.ToString()}\"", RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void JsonFormatting_Decimal_RendersCorrectly() { - // Arrange var decVal = 10.33m; var decProp = new LogEventProperty("Scalar", new ScalarValue(decVal)); var logEvent = new LogEvent(DateTimeOffset.Now, LogEventLevel.Information, null, _parser.Parse("{Scalar}"), new[] { decProp }); - // Act & Assert Assert.Equal(decVal.ToString(CultureInfo.InvariantCulture), RenderAndGetText(logEvent, "{Message:j}", CultureInfo.InvariantCulture)); } [Fact] public void OutputTemplate_WithSourceContextWithoutFormat_DoesNotCrash() { - // Arrange - Test for bug fix where {SourceContext} without format would cause exception var sourceContextProp = new LogEventProperty("SourceContext", new ScalarValue("MyApp.Services.UserService")); var template = _parser.Parse("User created successfully"); var logEvent = new LogEvent( @@ -133,20 +116,15 @@ public void OutputTemplate_WithSourceContextWithoutFormat_DoesNotCrash() new[] { sourceContextProp }); var outputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext}] {Message:lj}{NewLine}{Exception}"; - - // Act & Assert - Should not throw an exception var result = RenderAndGetText(logEvent, outputTemplate); - - // Verify the output contains expected parts Assert.Contains("[MyApp.Services.UserService]", result); Assert.Contains("User created successfully", result); - Assert.Contains("INF", result); // Level:u3 formatting + Assert.Contains("INF", result); } [Fact] public void OutputTemplate_WithSourceContextWithFormat_WorksCorrectly() { - // Arrange - Test that format specifiers on SourceContext work correctly var sourceContextProp = new LogEventProperty("SourceContext", new ScalarValue("MyApp.Services.UserService")); var template = _parser.Parse("User login attempt"); var logEvent = new LogEvent( @@ -157,14 +135,10 @@ public void OutputTemplate_WithSourceContextWithFormat_WorksCorrectly() new[] { sourceContextProp }); var outputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] [{SourceContext:u}] {Message:lj}{NewLine}{Exception}"; - - // Act & Assert - Should not throw an exception var result = RenderAndGetText(logEvent, outputTemplate); - - // Verify the output contains expected parts with uppercase SourceContext Assert.Contains("[MYAPP.SERVICES.USERSERVICE]", result); Assert.Contains("User login attempt", result); - Assert.Contains("WRN", result); // Level:u3 formatting + Assert.Contains("WRN", result); } [Theory] @@ -179,7 +153,6 @@ public void OutputTemplate_WithSourceContextWithFormat_WorksCorrectly() [InlineData("hello", null, "hello")] public void TextFormatter_WithEmptyValues_DoesNotCrash(string value, string? format, string expected) { - // Act & Assert - Should not throw an exception for empty values var result = TextFormatter.Format(value, format); Assert.Equal(expected, result); } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/TokenRendererTests.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/TokenRendererTests.cs new file mode 100644 index 0000000..4b68373 --- /dev/null +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/Integration/TokenRendererTests.cs @@ -0,0 +1,436 @@ +using Serilog.Events; +using Serilog.Parsing; +using Serilog.Sinks.RichTextBoxForms; +using Serilog.Sinks.RichTextBoxForms.Rendering; +using Xunit; + +namespace Serilog.Tests.Integration +{ + public class TokenRendererTests : RichTextBoxSinkTestBase + { + [Fact] + public void ExceptionTokenRenderer_RendersExceptionWithStackFrames() + { + Exception? exception; + try + { + throw new InvalidOperationException("Test exception"); + } + catch (InvalidOperationException ex) + { + exception = ex; + } + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Error, + exception, + _parser.Parse("Error occurred"), + Array.Empty()); + + var renderer = new ExceptionTokenRenderer(_defaultTheme); + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("InvalidOperationException", result); + Assert.Contains("Test exception", result); + + var lines = result.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); + Assert.True(lines.Length > 1, $"Exception should have multiple lines including stack trace. Got: {lines.Length} lines. Text: {result}"); + } + + [Fact] + public void ExceptionTokenRenderer_HandlesNullException() + { + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("No exception"), + Array.Empty()); + + var renderer = new ExceptionTokenRenderer(_defaultTheme); + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Empty(result); + } + + [Fact] + public void EventPropertyTokenRenderer_RendersNonStringScalarValue() + { + var template = _parser.Parse("Number: {Number}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "Number"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + new[] { new LogEventProperty("Number", new ScalarValue(42)) }); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("42", result); + } + + [Fact] + public void EventPropertyTokenRenderer_RendersStructureValue() + { + var template = _parser.Parse("User: {User}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "User"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var structureValue = new StructureValue(new[] + { + new LogEventProperty("Id", new ScalarValue(123)), + new LogEventProperty("Name", new ScalarValue("John")) + }, "User"); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + new[] { new LogEventProperty("User", structureValue) }); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("Id", result); + Assert.Contains("123", result); + Assert.Contains("Name", result); + Assert.Contains("John", result); + } + + [Fact] + public void EventPropertyTokenRenderer_RendersSequenceValue() + { + var template = _parser.Parse("Items: {Items}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "Items"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var sequenceValue = new SequenceValue(new[] + { + new ScalarValue(1), + new ScalarValue(2), + new ScalarValue(3) + }); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + new[] { new LogEventProperty("Items", sequenceValue) }); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("1", result); + Assert.Contains("2", result); + Assert.Contains("3", result); + } + + [Fact] + public void EventPropertyTokenRenderer_RendersDictionaryValue() + { + var template = _parser.Parse("Config: {Config}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "Config"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var dict = new Dictionary + { + { new ScalarValue("key1"), new ScalarValue("value1") }, + { new ScalarValue("key2"), new ScalarValue(42) } + }; + var dictionaryValue = new DictionaryValue(dict); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + new[] { new LogEventProperty("Config", dictionaryValue) }); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("key1", result); + Assert.Contains("value1", result); + Assert.Contains("key2", result); + Assert.Contains("42", result); + } + + [Fact] + public void EventPropertyTokenRenderer_HandlesMissingProperty() + { + var template = _parser.Parse("Missing: {Missing}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "Missing"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Empty(result); + } + + [Fact] + public void EventPropertyTokenRenderer_RendersStringValue() + { + var template = _parser.Parse("Message: {Message}"); + var propertyToken = template.Tokens.OfType().Single(t => t.PropertyName == "Message"); + var options = new RichTextBoxSinkOptions(_defaultTheme, formatProvider: null); + var renderer = new EventPropertyTokenRenderer(propertyToken, options); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + template, + new[] { new LogEventProperty("Message", new ScalarValue("Hello World")) }); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("Hello World", result); + } + + [Fact] + public void LevelTokenRenderer_WithLowercaseFormat_RendersCorrectly() + { + var template = _parser.Parse("Level: {Level:w3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("inf", result); + } + + [Fact] + public void LevelTokenRenderer_WithTitleCaseFormat_RendersCorrectly() + { + var template = _parser.Parse("Level: {Level:t3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Warning, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("Wrn", result); + } + + [Fact] + public void LevelTokenRenderer_WithUppercaseFormat_RendersCorrectly() + { + var template = _parser.Parse("Level: {Level:u3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Error, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("ERR", result); + } + + [Fact] + public void LevelTokenRenderer_WithThreeDigitWidth_RendersCorrectly() + { + var template = _parser.Parse("Level: {Level:u10}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("INFORMATIO", result); + } + + [Fact] + public void LevelTokenRenderer_WithWidthLessThanOne_ReturnsEmpty() + { + var template = _parser.Parse("Level: {Level:u0}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Empty(result); + } + + [Fact] + public void LevelTokenRenderer_WithWidthGreaterThanFour_TruncatesAndFormats() + { + var template = _parser.Parse("Level: {Level:u5}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("INFOR", result); + } + + [Fact] + public void LevelTokenRenderer_WithInvalidFormatSpecifier_UsesTextFormatter() + { + var template = _parser.Parse("Level: {Level:x3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("Information", result); + } + + [Fact] + public void LevelTokenRenderer_WithNonStandardFormatLength_UsesTextFormatter() + { + var template = _parser.Parse("Level: {Level:u}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Information, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("INFORMATION", result); + } + + [Fact] + public void LevelTokenRenderer_WithInvalidLevelIndex_HandlesGracefully() + { + var template = _parser.Parse("Level: {Level:u3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var invalidLevel = (LogEventLevel)999; + var logEvent = new LogEvent( + DateTimeOffset.Now, + invalidLevel, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("999", result); + } + + [Fact] + public void LevelTokenRenderer_WithNegativeLevelIndex_HandlesGracefully() + { + var template = _parser.Parse("Level: {Level:u3}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var invalidLevel = (LogEventLevel)(-1); + var logEvent = new LogEvent( + DateTimeOffset.Now, + invalidLevel, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("-1", result); + } + + [Fact] + public void LevelTokenRenderer_WithWidthGreaterThanStringLength_DoesNotTruncate() + { + var template = _parser.Parse("Level: {Level:u20}"); + var levelToken = template.Tokens.OfType().Single(t => t.PropertyName == "Level"); + var renderer = new LevelTokenRenderer(_defaultTheme, levelToken); + + var logEvent = new LogEvent( + DateTimeOffset.Now, + LogEventLevel.Verbose, + null, + _parser.Parse("Test"), + Array.Empty()); + + renderer.Render(logEvent, _canvas); + + var result = _richTextBox.Text; + Assert.Contains("VERBOSE", result); + } + } +} + diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs index 28e6efd..4855ec0 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored.Test/RichTextBoxSinkTestBase.cs @@ -20,15 +20,16 @@ protected RichTextBoxSinkTestBase() { _richTextBox = new RichTextBox(); _defaultTheme = ThemePresets.Literate; - _renderer = new TemplateRenderer(_defaultTheme, "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", null); _parser = new MessageTemplateParser(); var options = new RichTextBoxSinkOptions( theme: _defaultTheme, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:l}{NewLine}{Exception}", autoScroll: true, maxLogLines: 1000 ); + _renderer = new TemplateRenderer(options); _sink = new RichTextBoxSink(_richTextBox, options); // Wrap the RichTextBox in an IRtfCanvas adapter so that unit tests @@ -40,7 +41,17 @@ protected RichTextBoxSinkTestBase() protected string RenderAndGetText(LogEvent logEvent, string outputTemplate, IFormatProvider? formatProvider = null) { _richTextBox.Clear(); - var renderer = new TemplateRenderer(_defaultTheme, outputTemplate, formatProvider); + var options = new RichTextBoxSinkOptions(_defaultTheme, outputTemplate: outputTemplate, formatProvider: formatProvider); + var renderer = new TemplateRenderer(options); + renderer.Render(logEvent, _canvas); + return _richTextBox.Text.TrimEnd('\n', '\r'); + } + + protected string RenderAndGetText(LogEvent logEvent, string outputTemplate, RichTextBoxSinkOptions options) + { + _richTextBox.Clear(); + var rendererOptions = new RichTextBoxSinkOptions(options.Theme, options.AutoScroll, options.MaxLogLines, outputTemplate, options.FormatProvider, options.PrettyPrintJson, options.SpacesPerIndent); + var renderer = new TemplateRenderer(rendererOptions); renderer.Render(logEvent, _canvas); return _richTextBox.Text.TrimEnd('\n', '\r'); } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs index 1b36c38..6e094f1 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/RichTextBoxSinkLoggerConfigurationExtensions.cs @@ -45,6 +45,8 @@ public static class RichTextBoxSinkLoggerConfigurationExtensions /// Format provider, or null for invariant culture. /// Minimum log level for events to be written. /// Optional switch to change the minimum log level at runtime. + /// If true, formats JSON values with indentation and line breaks. Defaults to false. + /// Number of spaces per indentation level when pretty printing JSON. Defaults to 2. /// The logger configuration, for chaining. public static LoggerConfiguration RichTextBox( this LoggerSinkConfiguration sinkConfiguration, @@ -56,12 +58,14 @@ public static LoggerConfiguration RichTextBox( string outputTemplate = OutputTemplate, IFormatProvider? formatProvider = null, LogEventLevel minimumLogEventLevel = LogEventLevel.Verbose, - LoggingLevelSwitch? levelSwitch = null) + LoggingLevelSwitch? levelSwitch = null, + bool prettyPrintJson = false, + int spacesPerIndent = 2) { var appliedTheme = theme ?? ThemePresets.Literate; var appliedFormatProvider = formatProvider ?? CultureInfo.InvariantCulture; - var renderer = new TemplateRenderer(appliedTheme, outputTemplate, appliedFormatProvider); - var options = new RichTextBoxSinkOptions(appliedTheme, autoScroll, maxLogLines, outputTemplate, appliedFormatProvider); + var options = new RichTextBoxSinkOptions(appliedTheme, autoScroll, maxLogLines, outputTemplate, appliedFormatProvider, prettyPrintJson, spacesPerIndent); + var renderer = new TemplateRenderer(options); richTextBoxSink = new RichTextBoxSink(richTextBoxControl, options, renderer); return sinkConfiguration.Sink(richTextBoxSink, minimumLogEventLevel, levelSwitch); } @@ -78,6 +82,8 @@ public static LoggerConfiguration RichTextBox( /// Format provider, or null for invariant culture. /// Minimum log level for events to be written. /// Optional switch to change the minimum log level at runtime. + /// If true, formats JSON values with indentation and line breaks. Defaults to false. + /// Number of spaces per indentation level when pretty printing JSON. Defaults to 4. /// The logger configuration, for chaining. public static LoggerConfiguration RichTextBox( this LoggerSinkConfiguration sinkConfiguration, @@ -88,7 +94,9 @@ public static LoggerConfiguration RichTextBox( string outputTemplate = OutputTemplate, IFormatProvider? formatProvider = null, LogEventLevel minimumLogEventLevel = LogEventLevel.Verbose, - LoggingLevelSwitch? levelSwitch = null) + LoggingLevelSwitch? levelSwitch = null, + bool prettyPrintJson = false, + int spacesPerIndent = 2) { return RichTextBox( sinkConfiguration, @@ -100,7 +108,9 @@ public static LoggerConfiguration RichTextBox( outputTemplate, formatProvider, minimumLogEventLevel, - levelSwitch); + levelSwitch, + prettyPrintJson, + spacesPerIndent); } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj b/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj index 12c8b51..03d1317 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Serilog.Sinks.RichTextBox.WinForms.Colored.csproj @@ -22,10 +22,9 @@ net462;net471;net6.0-windows;net8.0-windows;net9.0-windows;netcoreapp3.0-windows;netcoreapp3.1-windows true true - 3.1.3 + 3.2.0 -- Fixed performance issues when logging large numbers of complex messages that could cause application freezing. -- Fixed `ArgumentOutOfRangeException` thrown from `RtfBuilder.Clear()` +- Added support for JSON pretty-printing in log messages. See repository for more information: https://github.com/vonhoff/Serilog.Sinks.RichTextBox.WinForms.Colored diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/DisplayValueFormatter.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/DisplayValueFormatter.cs index 50eecf6..f723404 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/DisplayValueFormatter.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/DisplayValueFormatter.cs @@ -28,14 +28,12 @@ namespace Serilog.Sinks.RichTextBoxForms.Formatting { public class DisplayValueFormatter : ValueFormatter { - private readonly IFormatProvider? _formatProvider; private readonly StringBuilder _scalarBuilder = new(); private readonly StringBuilder _literalBuilder = new(64); private JsonValueFormatter? _jsonValueFormatter; - public DisplayValueFormatter(Theme theme, IFormatProvider? formatProvider) : base(theme, formatProvider) + public DisplayValueFormatter(RichTextBoxSinkOptions options) : base(options) { - _formatProvider = formatProvider; } private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas, string? format, bool isLiteral) @@ -45,7 +43,7 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas, string? f switch (value) { case null: - Theme.Render(canvas, StyleToken.Null, "null"); + Options.Theme.Render(canvas, StyleToken.Null, "null"); return; case string text: @@ -55,15 +53,15 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas, string? f case byte[] bytes: _literalBuilder.Clear(); _literalBuilder.Append('"').Append(Convert.ToBase64String(bytes)).Append('"'); - Theme.Render(canvas, StyleToken.String, _literalBuilder.ToString()); + Options.Theme.Render(canvas, StyleToken.String, _literalBuilder.ToString()); return; case bool b: - Theme.Render(canvas, StyleToken.Boolean, b.ToString()); + Options.Theme.Render(canvas, StyleToken.Boolean, b.ToString()); return; case Uri uri: - Theme.Render(canvas, StyleToken.Scalar, uri.ToString()); + Options.Theme.Render(canvas, StyleToken.Scalar, uri.ToString()); return; case IFormattable formattable: @@ -75,10 +73,10 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas, string? f using (var writer = new StringWriter(_scalarBuilder)) { - scalar.Render(writer, null, _formatProvider); + scalar.Render(writer, null, Options.FormatProvider); } - Theme.Render(canvas, StyleToken.Scalar, _scalarBuilder.ToString()); + Options.Theme.Render(canvas, StyleToken.Scalar, _scalarBuilder.ToString()); } private void RenderString(string text, IRtfCanvas canvas, string? format, bool isLiteral) @@ -86,13 +84,13 @@ private void RenderString(string text, IRtfCanvas canvas, string? format, bool i var effectivelyLiteral = isLiteral || format != null && format.Contains("l"); if (effectivelyLiteral) { - Theme.Render(canvas, StyleToken.String, text); + Options.Theme.Render(canvas, StyleToken.String, text); } else { _literalBuilder.Clear(); _literalBuilder.Append('"').Append(text.Replace("\"", "\\\"")).Append('"'); - Theme.Render(canvas, StyleToken.String, _literalBuilder.ToString()); + Options.Theme.Render(canvas, StyleToken.String, _literalBuilder.ToString()); } } @@ -100,29 +98,29 @@ protected override bool VisitDictionaryValue(ValueFormatterState state, Dictiona { if (state.Format.Contains("j")) { - _jsonValueFormatter ??= new JsonValueFormatter(Theme, _formatProvider); + _jsonValueFormatter ??= new JsonValueFormatter(Options); _jsonValueFormatter.Format(dictionary, state.Canvas, state.Format, state.IsLiteral); return true; } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); var delimiter = string.Empty; foreach (var (scalarValue, propertyValue) in dictionary.Elements) { if (!string.IsNullOrEmpty(delimiter)) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); } delimiter = ", "; - Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); Visit(state.Next(), scalarValue); - Theme.Render(state.Canvas, StyleToken.TertiaryText, "]="); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "]="); Visit(state.Next(), propertyValue); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); return true; } @@ -136,26 +134,26 @@ protected override bool VisitSequenceValue(ValueFormatterState state, SequenceVa { if (state.Format.Contains("j")) { - _jsonValueFormatter ??= new JsonValueFormatter(Theme, _formatProvider); + _jsonValueFormatter ??= new JsonValueFormatter(Options); _jsonValueFormatter.Format(sequence, state.Canvas, state.Format, state.IsLiteral); return true; } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); var delimiter = string.Empty; foreach (var propertyValue in sequence.Elements) { if (!string.IsNullOrEmpty(delimiter)) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); } delimiter = ", "; Visit(state.Next(), propertyValue); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "]"); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "]"); return true; } @@ -163,33 +161,33 @@ protected override bool VisitStructureValue(ValueFormatterState state, Structure { if (state.Format.Contains("j")) { - _jsonValueFormatter ??= new JsonValueFormatter(Theme, _formatProvider); + _jsonValueFormatter ??= new JsonValueFormatter(Options); _jsonValueFormatter.Format(structure, state.Canvas, state.Format, state.IsLiteral); return true; } if (structure.TypeTag != null) { - Theme.Render(state.Canvas, StyleToken.Name, structure.TypeTag + " "); + Options.Theme.Render(state.Canvas, StyleToken.Name, structure.TypeTag + " "); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); var delimiter = string.Empty; foreach (var eventProperty in structure.Properties) { if (!string.IsNullOrEmpty(delimiter)) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); } delimiter = ", "; - Theme.Render(state.Canvas, StyleToken.Name, eventProperty.Name); - Theme.Render(state.Canvas, StyleToken.TertiaryText, "="); + Options.Theme.Render(state.Canvas, StyleToken.Name, eventProperty.Name); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "="); Visit(state.Next(), eventProperty.Value); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); return true; } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/JsonValueFormatter.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/JsonValueFormatter.cs index 11c79f3..5654420 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/JsonValueFormatter.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/JsonValueFormatter.cs @@ -29,14 +29,17 @@ namespace Serilog.Sinks.RichTextBoxForms.Formatting { public class JsonValueFormatter : ValueFormatter { - private readonly IFormatProvider? _formatProvider; - private readonly StringBuilder _literalBuilder = new(64); + private readonly StringBuilder _literalBuilder = new(); private readonly StringBuilder _scalarBuilder = new(); private readonly StringBuilder _jsonStringBuilder = new(); - public JsonValueFormatter(Theme theme, IFormatProvider? formatProvider) : base(theme, formatProvider) + public JsonValueFormatter(RichTextBoxSinkOptions options) : base(options) { - _formatProvider = formatProvider; + } + + protected override ValueFormatterState CreateInitialState(IRtfCanvas canvas, string format, bool isLiteral) + { + return new ValueFormatterState(canvas, format, isLiteral, 0, Options.SpacesPerIndent); } protected override bool VisitScalarValue(ValueFormatterState state, ScalarValue scalar) @@ -47,81 +50,212 @@ protected override bool VisitScalarValue(ValueFormatterState state, ScalarValue protected override bool VisitSequenceValue(ValueFormatterState state, SequenceValue sequence) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); + if (Options.PrettyPrintJson) + { + var indentState = state.ToIndentUp(); + if (sequence.Elements.Count > 0) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "[\n" + indentState.GetIndentation()); + } + else + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); + } + + var delimiter = string.Empty; + foreach (var propertyValue in sequence.Elements) + { + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } - var delimiter = string.Empty; - foreach (var propertyValue in sequence.Elements) + delimiter = ",\n" + indentState.GetIndentation(); + Visit(indentState, propertyValue); + } + + if (sequence.Elements.Count > 0) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "\n" + state.GetIndentation() + "]"); + } + else + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "]"); + } + } + else { - if (!string.IsNullOrEmpty(delimiter)) + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "["); + + var delimiter = string.Empty; + foreach (var propertyValue in sequence.Elements) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } + + delimiter = ", "; + Visit(state, propertyValue); } - delimiter = ", "; - Visit(state, propertyValue); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "]"); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "]"); return true; } protected override bool VisitStructureValue(ValueFormatterState state, StructureValue structure) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); - - var delimiter = string.Empty; - foreach (var eventProperty in structure.Properties) + if (Options.PrettyPrintJson) { - if (!string.IsNullOrEmpty(delimiter)) + var indentState = state.ToIndentUp(); + if (structure.Properties.Count > 0 || structure.TypeTag != null) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{\n" + indentState.GetIndentation()); + } + else { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); } - delimiter = ", "; - Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString(eventProperty.Name)); - Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); - Visit(state.Next(), eventProperty.Value); - } + var delimiter = string.Empty; + foreach (var eventProperty in structure.Properties) + { + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } + + delimiter = ",\n" + indentState.GetIndentation(); + Options.Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString(eventProperty.Name)); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + Visit(indentState.Next(), eventProperty.Value); + } - if (structure.TypeTag != null) + if (structure.TypeTag != null) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString("$type")); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + Options.Theme.Render(state.Canvas, StyleToken.String, GetQuotedJsonString(structure.TypeTag)); + } + + if (structure.Properties.Count > 0 || structure.TypeTag != null) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "\n" + state.GetIndentation() + "}"); + } + else + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); + } + } + else { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); - Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString("$type")); - Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); - Theme.Render(state.Canvas, StyleToken.String, GetQuotedJsonString(structure.TypeTag)); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); + + var delimiter = string.Empty; + foreach (var eventProperty in structure.Properties) + { + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } + + delimiter = ", "; + Options.Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString(eventProperty.Name)); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + Visit(state.Next(), eventProperty.Value); + } + + if (structure.TypeTag != null) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.Name, GetQuotedJsonString("$type")); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + Options.Theme.Render(state.Canvas, StyleToken.String, GetQuotedJsonString(structure.TypeTag)); + } + + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); return true; } protected override bool VisitDictionaryValue(ValueFormatterState state, DictionaryValue dictionary) { - Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); - - var delimiter = string.Empty; - foreach (var (scalar, propertyValue) in dictionary.Elements) + if (Options.PrettyPrintJson) { - if (!string.IsNullOrEmpty(delimiter)) + var indentState = state.ToIndentUp(); + if (dictionary.Elements.Count > 0) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{\n" + indentState.GetIndentation()); + } + else { - Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); } - delimiter = ", "; - var style = scalar.Value switch + var delimiter = string.Empty; + foreach (var (scalar, propertyValue) in dictionary.Elements) { - null => StyleToken.Null, - string => StyleToken.String, - _ => StyleToken.Scalar - }; + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } + + delimiter = ",\n" + indentState.GetIndentation(); + var style = scalar.Value switch + { + null => StyleToken.Null, + string => StyleToken.String, + _ => StyleToken.Scalar + }; - Theme.Render(state.Canvas, style, GetQuotedJsonString(scalar.Value?.ToString() ?? "null")); - Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + Options.Theme.Render(state.Canvas, style, GetQuotedJsonString(scalar.Value?.ToString() ?? "null")); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + + Visit(indentState.Next(), propertyValue); + } + + if (dictionary.Elements.Count > 0) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "\n" + state.GetIndentation() + "}"); + } + else + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); + } + } + else + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "{"); + + var delimiter = string.Empty; + foreach (var (scalar, propertyValue) in dictionary.Elements) + { + if (!string.IsNullOrEmpty(delimiter)) + { + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, delimiter); + } + + delimiter = ", "; + var style = scalar.Value switch + { + null => StyleToken.Null, + string => StyleToken.String, + _ => StyleToken.Scalar + }; + + Options.Theme.Render(state.Canvas, style, GetQuotedJsonString(scalar.Value?.ToString() ?? "null")); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, ": "); + + Visit(state.Next(), propertyValue); + } - Visit(state.Next(), propertyValue); + Options.Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); } - Theme.Render(state.Canvas, StyleToken.TertiaryText, "}"); return true; } @@ -132,40 +266,40 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas) switch (value) { case null: - Theme.Render(canvas, StyleToken.Null, "null"); + Options.Theme.Render(canvas, StyleToken.Null, "null"); return; case string str: - Theme.Render(canvas, StyleToken.String, GetQuotedJsonString(str)); + Options.Theme.Render(canvas, StyleToken.String, GetQuotedJsonString(str)); return; case byte[] bytes: - Theme.Render(canvas, StyleToken.String, GetQuotedJsonString(Convert.ToBase64String(bytes))); + Options.Theme.Render(canvas, StyleToken.String, GetQuotedJsonString(Convert.ToBase64String(bytes))); return; case bool b: - Theme.Render(canvas, StyleToken.Boolean, b ? "true" : "false"); + Options.Theme.Render(canvas, StyleToken.Boolean, b ? "true" : "false"); return; case double d: if (double.IsNaN(d) || double.IsInfinity(d)) { - Theme.Render(canvas, StyleToken.Number, GetQuotedJsonString(d.ToString(CultureInfo.InvariantCulture))); + Options.Theme.Render(canvas, StyleToken.Number, GetQuotedJsonString(d.ToString(CultureInfo.InvariantCulture))); } else { - Theme.Render(canvas, StyleToken.Number, d.ToString("R", CultureInfo.InvariantCulture)); + Options.Theme.Render(canvas, StyleToken.Number, d.ToString("R", CultureInfo.InvariantCulture)); } return; case float f: if (float.IsNaN(f) || float.IsInfinity(f)) { - Theme.Render(canvas, StyleToken.Number, GetQuotedJsonString(f.ToString(CultureInfo.InvariantCulture))); + Options.Theme.Render(canvas, StyleToken.Number, GetQuotedJsonString(f.ToString(CultureInfo.InvariantCulture))); } else { - Theme.Render(canvas, StyleToken.Number, f.ToString("R", CultureInfo.InvariantCulture)); + Options.Theme.Render(canvas, StyleToken.Number, f.ToString("R", CultureInfo.InvariantCulture)); } return; @@ -191,15 +325,21 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas) break; default: - scalar.Render(writer, null, _formatProvider); + scalar.Render(writer, null, Options.FormatProvider); break; } } - Theme.Render(canvas, StyleToken.Scalar, GetQuotedJsonString(_literalBuilder.ToString())); + Options.Theme.Render(canvas, StyleToken.Scalar, GetQuotedJsonString(_literalBuilder.ToString())); return; default: + if (value is ValueType and (int or uint or long or ulong or decimal or byte or sbyte or short or ushort)) + { + Options.Theme.Render(canvas, StyleToken.Number, ((IFormattable)value).ToString(null, CultureInfo.InvariantCulture)); + return; + } + if (value is IFormattable formattable) { RenderFormattable(canvas, formattable, null); @@ -210,63 +350,82 @@ private void FormatLiteralValue(ScalarValue scalar, IRtfCanvas canvas) using (var writer = new StringWriter(_scalarBuilder)) { - scalar.Render(writer, null, _formatProvider); + scalar.Render(writer, null, Options.FormatProvider); } - Theme.Render(canvas, StyleToken.Scalar, _scalarBuilder.ToString()); + Options.Theme.Render(canvas, StyleToken.Scalar, GetQuotedJsonString(_scalarBuilder.ToString())); return; } } - private static void WriteQuotedJsonString(StringBuilder builder, string str) + private string GetQuotedJsonString(string str) { - builder.Append('\"'); + _jsonStringBuilder.Clear(); + _jsonStringBuilder.Append('\"'); + + var cleanSegmentStart = 0; + var anyEscaped = false; + + for (var i = 0; i < str.Length; ++i) + { + var c = str[i]; + if (c < (char)32 || c == '\\' || c == '"') + { + anyEscaped = true; + + if (i > cleanSegmentStart) + { + _jsonStringBuilder.Append(str, cleanSegmentStart, i - cleanSegmentStart); + } + cleanSegmentStart = i + 1; + + switch (c) + { + case '"': + _jsonStringBuilder.Append("\\\""); + break; + + case '\\': + _jsonStringBuilder.Append("\\\\"); + break; + + case '\n': + _jsonStringBuilder.Append("\\n"); + break; + + case '\r': + _jsonStringBuilder.Append("\\r"); + break; + + case '\f': + _jsonStringBuilder.Append("\\f"); + break; + + case '\t': + _jsonStringBuilder.Append("\\t"); + break; + + default: + _jsonStringBuilder.Append("\\u"); + _jsonStringBuilder.Append(((int)c).ToString("X4")); + break; + } + } + } - foreach (var c in str) + if (anyEscaped) { - switch (c) + if (cleanSegmentStart < str.Length) { - case '"': - builder.Append("\\\""); - break; - - case '\\': - builder.Append(@"\\"); - break; - - case '\n': - builder.Append("\\n"); - break; - - case '\r': - builder.Append("\\r"); - break; - - case '\f': - builder.Append("\\f"); - break; - - case '\t': - builder.Append("\\t"); - break; - - case < (char)32: - builder.Append("\\u"); - builder.Append(((int)c).ToString("X4")); - break; - - default: - builder.Append(c); - break; + _jsonStringBuilder.Append(str, cleanSegmentStart, str.Length - cleanSegmentStart); } } - builder.Append('\"'); - } + else + { + _jsonStringBuilder.Append(str); + } - private string GetQuotedJsonString(string str) - { - _jsonStringBuilder.Clear(); - WriteQuotedJsonString(_jsonStringBuilder, str); + _jsonStringBuilder.Append('\"'); return _jsonStringBuilder.ToString(); } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatter.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatter.cs index 9fd08e4..f893bee 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatter.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatter.cs @@ -28,26 +28,24 @@ namespace Serilog.Sinks.RichTextBoxForms.Formatting public abstract class ValueFormatter : LogEventPropertyValueVisitor { private readonly StringBuilder _formatBuilder = new(); - private readonly IFormatProvider? _formatProvider; - protected ValueFormatter(Theme theme, IFormatProvider? formatProvider = null) + protected ValueFormatter(RichTextBoxSinkOptions options) { - Theme = theme; - _formatProvider = formatProvider; + Options = options; } - protected Theme Theme { get; } + protected RichTextBoxSinkOptions Options { get; } public void Format(LogEventPropertyValue value, IRtfCanvas canvas, string format, bool isLiteral) { - Visit(new ValueFormatterState(canvas, format, isLiteral), value); + Visit(CreateInitialState(canvas, format, isLiteral), value); + } + + protected virtual ValueFormatterState CreateInitialState(IRtfCanvas canvas, string format, bool isLiteral) + { + return new ValueFormatterState(canvas, format, isLiteral); } - /// - /// Determines the appropriate StyleToken for a given type. - /// - /// The type to analyze - /// StyleToken.Number for numeric types, StyleToken.Scalar for others protected static StyleToken GetStyleTokenForType(Type type) { return type == typeof(float) || type == typeof(double) || type == typeof(decimal) || @@ -100,15 +98,15 @@ protected void RenderFormattable(IRtfCanvas canvas, IFormattable formattable, st string renderedValue; try { - renderedValue = formattable.ToString(effectiveFormat, _formatProvider); + renderedValue = formattable.ToString(effectiveFormat, Options.FormatProvider); } catch (FormatException) { // Fall back to the default formatting if the specified format is not supported by the value. - renderedValue = formattable.ToString(null, _formatProvider); + renderedValue = formattable.ToString(null, Options.FormatProvider); } - Theme.Render(canvas, token, renderedValue); + Options.Theme.Render(canvas, token, renderedValue); } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatterState.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatterState.cs index 3729a91..ad05c30 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatterState.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Formatting/ValueFormatterState.cs @@ -22,20 +22,46 @@ namespace Serilog.Sinks.RichTextBoxForms.Formatting { public readonly struct ValueFormatterState { - public ValueFormatterState(IRtfCanvas canvas, string format, bool isLiteral) + public ValueFormatterState(IRtfCanvas canvas, string format, bool isLiteral, int indentLevel = 0, int spacesPerIndent = 2, bool isTopLevel = true) { Canvas = canvas; Format = format; IsLiteral = isLiteral; + IndentLevel = indentLevel; + SpacesPerIndent = spacesPerIndent; + IsTopLevel = isTopLevel; } public string Format { get; } public bool IsLiteral { get; } public IRtfCanvas Canvas { get; } + public int IndentLevel { get; } + public int SpacesPerIndent { get; } + public bool IsTopLevel { get; } public ValueFormatterState Next(string? format = null) { - return new ValueFormatterState(Canvas, format ?? Format, IsLiteral); + return new ValueFormatterState(Canvas, format ?? Format, IsLiteral, IndentLevel, SpacesPerIndent, false); + } + + public ValueFormatterState ToIndentUp() + { + return new ValueFormatterState(Canvas, Format, IsLiteral, IndentLevel + 1, SpacesPerIndent, false); + } + + public ValueFormatterState ToIndentDown() + { + return new ValueFormatterState(Canvas, Format, IsLiteral, IndentLevel > 0 ? IndentLevel - 1 : 0, SpacesPerIndent, false); + } + + public string GetIndentation() + { + if (IndentLevel <= 0) + { + return string.Empty; + } + + return new string(' ', IndentLevel * SpacesPerIndent); } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/EventPropertyTokenRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/EventPropertyTokenRenderer.cs index 78093d2..d18b2da 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/EventPropertyTokenRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/EventPropertyTokenRenderer.cs @@ -21,7 +21,6 @@ using Serilog.Sinks.RichTextBoxForms.Formatting; using Serilog.Sinks.RichTextBoxForms.Rtf; using Serilog.Sinks.RichTextBoxForms.Themes; -using System; using System.IO; using System.Text; @@ -29,15 +28,14 @@ namespace Serilog.Sinks.RichTextBoxForms.Rendering { public class EventPropertyTokenRenderer : ITokenRenderer { - private readonly IFormatProvider? _formatProvider; - private readonly Theme _theme; + private readonly RichTextBoxSinkOptions _options; private readonly PropertyToken _token; + private readonly StringBuilder _stringBuilder = new(); - public EventPropertyTokenRenderer(Theme theme, PropertyToken token, IFormatProvider? formatProvider) + public EventPropertyTokenRenderer(PropertyToken token, RichTextBoxSinkOptions options) { - _theme = theme; _token = token; - _formatProvider = formatProvider; + _options = options; } public void Render(LogEvent logEvent, IRtfCanvas canvas) @@ -50,18 +48,18 @@ public void Render(LogEvent logEvent, IRtfCanvas canvas) if (propertyValue is ScalarValue { Value: string literalString }) { var cased = TextFormatter.Format(literalString, _token.Format); - _theme.Render(canvas, StyleToken.SecondaryText, cased); + _options.Theme.Render(canvas, StyleToken.SecondaryText, cased); } else { - var sb = new StringBuilder(); + _stringBuilder.Clear(); - using (var writer = new StringWriter(sb)) + using (var writer = new StringWriter(_stringBuilder)) { - propertyValue.Render(writer, _token.Format, _formatProvider); + propertyValue.Render(writer, _token.Format, _options.FormatProvider); } - _theme.Render(canvas, StyleToken.SecondaryText, sb.ToString()); + _options.Theme.Render(canvas, StyleToken.SecondaryText, _stringBuilder.ToString()); } } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/LevelTokenRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/LevelTokenRenderer.cs index a499f44..b535663 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/LevelTokenRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/LevelTokenRenderer.cs @@ -82,6 +82,14 @@ public LevelTokenRenderer(Theme theme, PropertyToken levelToken) public void Render(LogEvent logEvent, IRtfCanvas canvas) { var levelIndex = (int)logEvent.Level; + if (levelIndex < 0 || levelIndex >= _monikers.Length) + { + var format = string.Empty; + var fallbackMoniker = TextFormatter.Format(logEvent.Level.ToString(), format); + _theme.Render(canvas, StyleToken.Text, fallbackMoniker); + return; + } + var moniker = _monikers[levelIndex]; var levelStyle = LevelStyles[levelIndex]; _theme.Render(canvas, levelStyle, moniker); @@ -119,11 +127,6 @@ private static string GetLevelMoniker(LogEventLevel value, string format = "") } var index = (int)value; - if (index is < 0 or > (int)LogEventLevel.Fatal) - { - return TextFormatter.Format(value.ToString(), format); - } - return format[0] switch { 'w' => LowercaseLevelMap[index][width - 1], diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/MessageTemplateTokenRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/MessageTemplateTokenRenderer.cs index bd34079..e6f8474 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/MessageTemplateTokenRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/MessageTemplateTokenRenderer.cs @@ -20,8 +20,6 @@ using Serilog.Parsing; using Serilog.Sinks.RichTextBoxForms.Formatting; using Serilog.Sinks.RichTextBoxForms.Rtf; -using Serilog.Sinks.RichTextBoxForms.Themes; -using System; namespace Serilog.Sinks.RichTextBoxForms.Rendering { @@ -29,16 +27,16 @@ public class MessageTemplateTokenRenderer : ITokenRenderer { private readonly MessageTemplateRenderer _renderer; - public MessageTemplateTokenRenderer(Theme theme, PropertyToken token, IFormatProvider? formatProvider) + public MessageTemplateTokenRenderer(PropertyToken token, RichTextBoxSinkOptions options) { var isLiteral = token.Format?.Contains("l") == true; var isJson = token.Format?.Contains("j") == true; ValueFormatter valueFormatter = isJson - ? new JsonValueFormatter(theme, formatProvider) - : new DisplayValueFormatter(theme, formatProvider); + ? new JsonValueFormatter(options) + : new DisplayValueFormatter(options); - _renderer = new MessageTemplateRenderer(theme, valueFormatter, isLiteral); + _renderer = new MessageTemplateRenderer(options.Theme, valueFormatter, isLiteral); } public void Render(LogEvent logEvent, IRtfCanvas canvas) diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/PropertiesTokenRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/PropertiesTokenRenderer.cs index 5033010..a5506e7 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/PropertiesTokenRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/PropertiesTokenRenderer.cs @@ -20,8 +20,6 @@ using Serilog.Parsing; using Serilog.Sinks.RichTextBoxForms.Formatting; using Serilog.Sinks.RichTextBoxForms.Rtf; -using Serilog.Sinks.RichTextBoxForms.Themes; -using System; using System.Collections.Generic; using System.Linq; @@ -32,11 +30,11 @@ public class PropertiesTokenRenderer : ITokenRenderer private readonly ValueFormatter _valueFormatter; private readonly HashSet _outputTemplateProperties; - public PropertiesTokenRenderer(Theme theme, PropertyToken token, MessageTemplate outputTemplate, IFormatProvider? formatProvider) + public PropertiesTokenRenderer(PropertyToken token, MessageTemplate outputTemplate, RichTextBoxSinkOptions options) { _valueFormatter = token.Format?.Contains("j") == true - ? new JsonValueFormatter(theme, formatProvider) - : new DisplayValueFormatter(theme, formatProvider); + ? new JsonValueFormatter(options) + : new DisplayValueFormatter(options); _outputTemplateProperties = new HashSet( outputTemplate.Tokens.OfType().Select(p => p.PropertyName)); diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TemplateRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TemplateRenderer.cs index be30ec4..d09cce5 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TemplateRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TemplateRenderer.cs @@ -20,7 +20,6 @@ using Serilog.Formatting.Display; using Serilog.Parsing; using Serilog.Sinks.RichTextBoxForms.Rtf; -using Serilog.Sinks.RichTextBoxForms.Themes; using System; using System.Collections.Generic; @@ -28,23 +27,22 @@ namespace Serilog.Sinks.RichTextBoxForms.Rendering { public class TemplateRenderer : ITokenRenderer { - private const string OutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"; private readonly List _renderers; - public TemplateRenderer(Theme theme, string outputTemplate = OutputTemplate, IFormatProvider? formatProvider = null) + public TemplateRenderer(RichTextBoxSinkOptions options) { - if (string.IsNullOrEmpty(outputTemplate)) + if (string.IsNullOrEmpty(options.OutputTemplate)) { - throw new ArgumentNullException(nameof(outputTemplate)); + throw new ArgumentNullException(nameof(options.OutputTemplate)); } - var template = new MessageTemplateParser().Parse(outputTemplate); + var template = new MessageTemplateParser().Parse(options.OutputTemplate); _renderers = new List(); foreach (var token in template.Tokens) { if (token is TextToken textToken) { - _renderers.Add(new TextTokenRenderer(theme, textToken.Text)); + _renderers.Add(new TextTokenRenderer(options.Theme, textToken.Text)); continue; } @@ -54,7 +52,7 @@ public TemplateRenderer(Theme theme, string outputTemplate = OutputTemplate, IFo { case OutputProperties.LevelPropertyName: { - _renderers.Add(new LevelTokenRenderer(theme, propertyToken)); + _renderers.Add(new LevelTokenRenderer(options.Theme, propertyToken)); break; } @@ -66,31 +64,31 @@ public TemplateRenderer(Theme theme, string outputTemplate = OutputTemplate, IFo case OutputProperties.ExceptionPropertyName: { - _renderers.Add(new ExceptionTokenRenderer(theme)); + _renderers.Add(new ExceptionTokenRenderer(options.Theme)); break; } case OutputProperties.MessagePropertyName: { - _renderers.Add(new MessageTemplateTokenRenderer(theme, propertyToken, formatProvider)); + _renderers.Add(new MessageTemplateTokenRenderer(propertyToken, options)); break; } case OutputProperties.TimestampPropertyName: { - _renderers.Add(new TimestampTokenRenderer(theme, propertyToken, formatProvider)); + _renderers.Add(new TimestampTokenRenderer(propertyToken, options)); break; } case OutputProperties.PropertiesPropertyName: { - _renderers.Add(new PropertiesTokenRenderer(theme, propertyToken, template, formatProvider)); + _renderers.Add(new PropertiesTokenRenderer(propertyToken, template, options)); break; } default: { - _renderers.Add(new EventPropertyTokenRenderer(theme, propertyToken, formatProvider)); + _renderers.Add(new EventPropertyTokenRenderer(propertyToken, options)); break; } } diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TimestampTokenRenderer.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TimestampTokenRenderer.cs index 616e047..59d6a0e 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TimestampTokenRenderer.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/Rendering/TimestampTokenRenderer.cs @@ -20,26 +20,23 @@ using Serilog.Parsing; using Serilog.Sinks.RichTextBoxForms.Rtf; using Serilog.Sinks.RichTextBoxForms.Themes; -using System; namespace Serilog.Sinks.RichTextBoxForms.Rendering { public class TimestampTokenRenderer : ITokenRenderer { - private readonly IFormatProvider? _formatProvider; - private readonly Theme _theme; + private readonly RichTextBoxSinkOptions _options; private readonly PropertyToken _token; - public TimestampTokenRenderer(Theme theme, PropertyToken token, IFormatProvider? formatProvider) + public TimestampTokenRenderer(PropertyToken token, RichTextBoxSinkOptions options) { - _theme = theme; _token = token; - _formatProvider = formatProvider; + _options = options; } public void Render(LogEvent logEvent, IRtfCanvas canvas) { - _theme.Render(canvas, StyleToken.SecondaryText, logEvent.Timestamp.ToString(_token.Format, _formatProvider)); + _options.Theme.Render(canvas, StyleToken.SecondaryText, logEvent.Timestamp.ToString(_token.Format, _options.FormatProvider)); } } } \ No newline at end of file diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs index 365eac4..2bba4c1 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSink.cs @@ -45,7 +45,7 @@ public RichTextBoxSink(RichTextBox richTextBox, RichTextBoxSinkOptions options, { _options = options; _richTextBox = richTextBox; - _renderer = renderer ?? new TemplateRenderer(options.Theme, options.OutputTemplate, options.FormatProvider); + _renderer = renderer ?? new TemplateRenderer(options); _tokenSource = new CancellationTokenSource(); _buffer = new ConcurrentCircularBuffer(options.MaxLogLines); diff --git a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSinkOptions.cs b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSinkOptions.cs index 53551f7..e291d3e 100644 --- a/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSinkOptions.cs +++ b/Serilog.Sinks.RichTextBox.WinForms.Colored/Sinks/RichTextBoxForms/RichTextBoxSinkOptions.cs @@ -26,6 +26,7 @@ public class RichTextBoxSinkOptions { private const string DefaultOutputTemplate = "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}"; private int _maxLogLines; + private int _spacesPerIndent; /// /// Creates a new collection of options that control the behavior and appearance of a @@ -36,18 +37,24 @@ public class RichTextBoxSinkOptions /// Maximum number of log events retained in the in-memory circular buffer and rendered in the control. /// Serilog output template that controls textual formatting of each log event. /// Optional culture-specific or custom formatting provider used when rendering scalar values; null for the invariant culture. + /// When true, formats JSON values with indentation and line breaks for better readability. Defaults to false. + /// Number of spaces per indentation level when pretty printing JSON. Defaults to 2. public RichTextBoxSinkOptions( Theme theme, bool autoScroll = true, int maxLogLines = 256, string outputTemplate = DefaultOutputTemplate, - IFormatProvider? formatProvider = null) + IFormatProvider? formatProvider = null, + bool prettyPrintJson = false, + int spacesPerIndent = 2) { AutoScroll = autoScroll; Theme = theme; MaxLogLines = maxLogLines; OutputTemplate = outputTemplate; FormatProvider = formatProvider ?? CultureInfo.InvariantCulture; + PrettyPrintJson = prettyPrintJson; + SpacesPerIndent = spacesPerIndent; } public bool AutoScroll { get; set; } @@ -68,5 +75,18 @@ public int MaxLogLines public string OutputTemplate { get; } public IFormatProvider? FormatProvider { get; } + + public bool PrettyPrintJson { get; } + + public int SpacesPerIndent + { + get => _spacesPerIndent; + private set => _spacesPerIndent = value switch + { + < 0 => 0, + > 16 => 16, + _ => value + }; + } } } \ No newline at end of file