diff --git a/examples/Demo/Program.cs b/examples/Demo/Program.cs index 4ec1a49..792601b 100644 --- a/examples/Demo/Program.cs +++ b/examples/Demo/Program.cs @@ -17,7 +17,12 @@ public static void Main() Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") .WithCode("CS0019") .WithNote("Try changing the type") + .WithLabel(new Label("Demo/Files/Program.cs", new Location(3, 2), "Not sure what this is") + .WithLength(5) + .WithPriority(1) + .WithColor(Color.Yellow)) .WithLabel(new Label("Demo/Files/Program.cs", new Location(15, 23), "This is of type 'int'") + .WithContextLines(2) .WithLength(3) .WithPriority(1) .WithColor(Color.Yellow)) @@ -33,6 +38,7 @@ public static void Main() Diagnostic.Info("Fix formatting") .WithCode("IDE0055")) .WithLabel(new Label("Demo/Files/Program.cs", 174..176, "Code should not contain trailing whitespace") + .WithColor(Color.Blue)); // Markdown @@ -44,6 +50,7 @@ public static void Main() .WithLabel(new Label("Demo/Files/Example.md", 31..41, "Invalid markdown") .WithColor(Color.Red)) .WithLabel(new Label("Demo/Files/Example.md", 251..270, "Did you mean 'Yabba dabba doo'?") + .WithContextLines(3) .WithColor(Color.Yellow))); // C++ diff --git a/src/Errata.Tests/Expectations/Report/Context/ContextAtStartOfFile.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/ContextAtStartOfFile.Output.verified.txt new file mode 100644 index 0000000..b769672 --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/ContextAtStartOfFile.Output.verified.txt @@ -0,0 +1,8 @@ +Error [CS0116]: Namespace cannot be empty + ┌─[Program.cs] + │ + 1 │ using System; + · ────┬──── + · ╰──────── Namespace is empty + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/Expectations/Report/Context/DefaultContextLines.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/DefaultContextLines.Output.verified.txt new file mode 100644 index 0000000..7a70bf4 --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/DefaultContextLines.Output.verified.txt @@ -0,0 +1,10 @@ +Error [CS0019]: Operator '/' cannot be applied to operands of type 'string' and 'int' + ┌─[Program.cs] + │ + 13 │ var foo = 1; + 14 │ var bar = "lol"; + 15 │ var qux = foo / bar; + · ─┬─ + · ╰──────── This is of type 'int' + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/Expectations/Report/Context/DefaultContextLinesOverride.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/DefaultContextLinesOverride.Output.verified.txt new file mode 100644 index 0000000..b181b06 --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/DefaultContextLinesOverride.Output.verified.txt @@ -0,0 +1,13 @@ +Error [CS0019]: Operator '/' cannot be applied to operands of type 'string' and 'int' + ┌─[Program.cs] + │ + 10 │ { + 11 │ public static void Main() + 12 │ { + 13 │ var foo = 1; + 14 │ var bar = "lol"; + 15 │ var qux = foo / bar; + · ─┬─ + · ╰──────── This is of type 'int' + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/Expectations/Report/Context/MultipleContext.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/MultipleContext.Output.verified.txt new file mode 100644 index 0000000..22dfa5c --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/MultipleContext.Output.verified.txt @@ -0,0 +1,11 @@ +Error [CS0019]: Operator '/' cannot be applied to operands of type 'string' and 'int' + ┌─[Program.cs] + │ + 12 │ { + 13 │ var foo = 1; + 14 │ var bar = "lol"; + 15 │ var qux = foo / bar; + · ─┬─ + · ╰──────── This is of type 'int' + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/Expectations/Report/Context/MultipleLabelsWithContext.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/MultipleLabelsWithContext.Output.verified.txt new file mode 100644 index 0000000..a9f67eb --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/MultipleLabelsWithContext.Output.verified.txt @@ -0,0 +1,15 @@ +Error [CS0019]: Operator '/' cannot be applied to operands of type 'string' and 'int' +NOTE: Try changing the type + ┌─[Program.cs] + │ + 13 │ var foo = 1; + 14 │ var bar = "lol"; + 15 │ var qux = foo / bar; + · ─┬─ ┬ ─┬─ + · ╰──────── This is of type 'int' + · │ │ + · ╰───── Division is not possible + · │ + · ╰── This is of type 'string' + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/Expectations/Report/Context/SingleContext.Output.verified.txt b/src/Errata.Tests/Expectations/Report/Context/SingleContext.Output.verified.txt new file mode 100644 index 0000000..018ecca --- /dev/null +++ b/src/Errata.Tests/Expectations/Report/Context/SingleContext.Output.verified.txt @@ -0,0 +1,9 @@ +Error [CS0019]: Operator '/' cannot be applied to operands of type 'string' and 'int' + ┌─[Program.cs] + │ + 14 │ var bar = "lol"; + 15 │ var qux = foo / bar; + · ─┬─ + · ╰──────── This is of type 'int' + │ + └─ \ No newline at end of file diff --git a/src/Errata.Tests/LabelTests.cs b/src/Errata.Tests/LabelTests.cs index 58eb150..6d11730 100644 --- a/src/Errata.Tests/LabelTests.cs +++ b/src/Errata.Tests/LabelTests.cs @@ -34,4 +34,56 @@ public void Should_Set_Note_To_Null_If_Null_Is_Provided() label.Note.ShouldBeNull(); } } + + public sealed class TheWithContextMethod + { + [Fact] + public void Should_Set_ContextLines_To_Provided_Value() + { + // Given + var label = new Label("Program.cs", new Location(1, 2), "The message"); + + // When + label.WithContextLines(3); + + // Then + label.ContextLines.ShouldBe(3); + } + + [Fact] + public void Should_Return_Same_Instance_For_Chaining() + { + // Given + var label = new Label("Program.cs", new Location(1, 2), "The message"); + + // When + var result = label.WithContextLines(2); + + // Then + result.ShouldBeSameAs(label); + } + + [Fact] + public void Should_Throw_If_Negative_Value_Is_Provided() + { + // Given + var label = new Label("Program.cs", new Location(1, 2), "The message"); + + // When, Then + Should.Throw(() => label.WithContextLines(-1)); + } + + [Fact] + public void Should_Allow_Zero_Context_Lines() + { + // Given + var label = new Label("Program.cs", new Location(1, 2), "The message"); + + // When + label.WithContextLines(0); + + // Then + label.ContextLines.ShouldBe(0); + } + } } diff --git a/src/Errata.Tests/ReportTests.cs b/src/Errata.Tests/ReportTests.cs index d6c9b9a..da97d7f 100644 --- a/src/Errata.Tests/ReportTests.cs +++ b/src/Errata.Tests/ReportTests.cs @@ -462,4 +462,158 @@ public Task Should_Render_Errata_Errors_Correctly() return Verifier.Verify(console.Output); } } + + [ExpectationPath("Context")] + public sealed class WithContextLines + { + [Fact] + [Expectation("SingleContext")] + public Task Should_Render_Label_With_One_Context_Line_Correctly() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") + .WithCode("CS0019") + .WithLabel(new Label("Program.cs", new Location(15, 23), "This is of type 'int'") + .WithColor(Color.Yellow) + .WithLength(3) + .WithContextLines(1))); + + // When + report.Render(console); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("MultipleContext")] + public Task Should_Render_Label_With_Multiple_Context_Lines_Correctly() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") + .WithCode("CS0019") + .WithLabel(new Label("Program.cs", new Location(15, 23), "This is of type 'int'") + .WithColor(Color.Yellow) + .WithLength(3) + .WithContextLines(3))); + + // When + report.Render(console); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("MultipleLabelsWithContext")] + public Task Should_Render_Multiple_Labels_With_Different_Context_Lines_Correctly() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") + .WithCode("CS0019") + .WithNote("Try changing the type") + .WithLabel(new Label("Program.cs", new Location(15, 23), "This is of type 'int'") + .WithColor(Color.Yellow) + .WithLength(3) + .WithContextLines(2)) + .WithLabel(new Label("Program.cs", new Location(15, 27), "Division is not possible") + .WithColor(Color.Red) + .WithLength(1)) + .WithLabel(new Label("Program.cs", new Location(15, 29), "This is of type 'string'") + .WithColor(Color.Blue) + .WithLength(3))); + + // When + report.Render(console); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("ContextAtStartOfFile")] + public Task Should_Render_Label_With_Context_At_Start_Of_File_Correctly() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Namespace cannot be empty") + .WithCode("CS0116") + .WithLabel(new Label("Program.cs", new Location(1, 1), "Namespace is empty") + .WithColor(Color.Red) + .WithLength(9) + .WithContextLines(5))); + + // When + report.Render(console); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("DefaultContextLines")] + public Task Should_Apply_Default_Context_Lines_From_Settings() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") + .WithCode("CS0019") + .WithLabel(new Label("Program.cs", new Location(15, 23), "This is of type 'int'") + .WithColor(Color.Yellow) + .WithLength(3))); + + // When - Set default context lines to 2 + report.Render(console, new ReportSettings + { + DefaultContextLines = 2, + }); + + // Then + return Verifier.Verify(console.Output); + } + + [Fact] + [Expectation("DefaultContextLinesOverride")] + public Task Should_Allow_Label_To_Override_Default_Context_Lines() + { + // Given + var console = new TestConsole().Width(80); + var report = new Report(new EmbeddedResourceRepository()); + + report.AddDiagnostic( + Diagnostic.Error("Operator '/' cannot be applied to operands of type 'string' and 'int'") + .WithCode("CS0019") + .WithLabel(new Label("Program.cs", new Location(15, 23), "This is of type 'int'") + .WithColor(Color.Yellow) + .WithLength(3) + .WithContextLines(5))); // Override default with 5 + + // When - Set default context lines to 2 + report.Render(console, new ReportSettings + { + DefaultContextLines = 2, + }); + + // Then + return Verifier.Verify(console.Output); + } + } } diff --git a/src/Errata/Label.cs b/src/Errata/Label.cs index a933817..0a0c04e 100644 --- a/src/Errata/Label.cs +++ b/src/Errata/Label.cs @@ -37,6 +37,11 @@ public sealed class Label /// public int Priority { get; set; } = 0; + /// + /// Gets or sets the number of additional context lines to display above this label. + /// + public int ContextLines { get; set; } = 0; + #if NET6_0_OR_GREATER /// /// Initializes a new instance of the class. diff --git a/src/Errata/LabelExtensions.cs b/src/Errata/LabelExtensions.cs index 0dc7945..0c74a3a 100644 --- a/src/Errata/LabelExtensions.cs +++ b/src/Errata/LabelExtensions.cs @@ -58,4 +58,26 @@ public static Label WithPriority(this Label label, int priority) label.Priority = priority; return label; } + + /// + /// Sets the number of additional context lines to display above this label. + /// + /// The label. + /// The number of lines above the label to display as context. + /// The same instance so that multiple calls can be chained. + public static Label WithContextLines(this Label label, int lines) + { + if (label is null) + { + throw new ArgumentNullException(nameof(label)); + } + + if (lines < 0) + { + throw new ArgumentOutOfRangeException(nameof(lines), "Context lines must be greater than or equal to zero"); + } + + label.ContextLines = lines; + return label; + } } diff --git a/src/Errata/Rendering/DiagnosticRenderer.cs b/src/Errata/Rendering/DiagnosticRenderer.cs index 7231c41..c39b96a 100644 --- a/src/Errata/Rendering/DiagnosticRenderer.cs +++ b/src/Errata/Rendering/DiagnosticRenderer.cs @@ -63,48 +63,71 @@ public void Render(Diagnostic diagnostic) .ToList(); // Iterate all lines in the line range - foreach (var (_, _, lastLine, lineIndex) in group.Source.GetLineRange(group.Span).Enumerate()) + var lineIndices = group.Source.GetLineRange(group.Span).ToList(); + for (var i = 0; i < lineIndices.Count; i++) { + var lineIndex = lineIndices[i]; + var lastLine = i == lineIndices.Count - 1; + // Get the current line var line = group.Source.Lines[lineIndex]; // Get all labels for the current line var labels = group.GetLabelsForLine(line); - if (labels.Count == 0) + + // Determine if this is a context-only line (marked as context but has no labels) + var isContextLine = group.IsContextLine(lineIndex) && labels.Count == 0; + + if (isContextLine) { - continue; + // Render context line with dim styling + RenderContextLine(ctx, line); } + else if (labels.Count > 0) + { + // Write text line + RenderText(ctx, line, labels); - // Write text line - RenderText(ctx, line, labels); + // Write labels + foreach (var (row, label) in labels.EnumerateWithIndex()) + { + // Render anchors and vertical lines + RenderVerticalLines(ctx, line, labels, row); - // Write labels - foreach (var (row, label) in labels.EnumerateWithIndex()) - { - // Render anchors and vertical lines - RenderVerticalLines(ctx, line, labels, row); + // Render the horizontal lines + RenderHorizontalLines(ctx, line, labels, row, label); - // Render the horizontal lines - RenderHorizontalLines(ctx, line, labels, row, label); + // Render the label message + if (label.ShouldRenderMessage) + { + // 🔎 The label message + ctx.Builder.AppendSpace(); + ctx.Builder.Append(label.Message, label.Color); + } - // Render the label message - if (label.ShouldRenderMessage) - { - // 🔎 The label message - ctx.Builder.AppendSpace(); - ctx.Builder.Append(label.Message, label.Color); + // 🔎 \n + ctx.Builder.CommitLine(); } - - // 🔎 \n - ctx.Builder.CommitLine(); + } + else + { + // Skip lines that have no labels and are not context lines + continue; } + // Only render separator if not the last line AND there's a gap to the next line if (!lastLine) { - // 🔎 ···(dot)\n - ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); - ctx.Builder.Append(Character.Dot, Color.Grey); - ctx.Builder.CommitLine(); + var nextLineIndex = lineIndices[i + 1]; + var hasGap = nextLineIndex != lineIndex + 1; + + if (hasGap) + { + // 🔎 ···(dot)\n + ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); + ctx.Builder.Append(Character.Dot, Color.Grey); + ctx.Builder.CommitLine(); + } } } @@ -116,29 +139,26 @@ public void Render(Diagnostic diagnostic) // Got labels with notes? var labelsWithNotes = group.Labels.Where(l => l.Note != null).ToArray(); - if (labelsWithNotes != null) + foreach (var (_, _, lastLabel, labelWithNote) in labelsWithNotes.Enumerate()) { - foreach (var (_, firstLabel, lastLabel, labelWithNote) in labelsWithNotes.Enumerate()) + // Got a note? + if (!string.IsNullOrWhiteSpace(labelWithNote.Note)) { - // Got a note? - if (!string.IsNullOrWhiteSpace(labelWithNote.Note)) - { - // 🔎 ···(dot) NOTE: This is a note\n - ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); - ctx.Builder.Append(Character.Dot, Color.Grey); - ctx.Builder.AppendSpace(); - ctx.Builder.Append("NOTE: ", Color.Aqua); - ctx.Builder.Append(labelWithNote.Note); - ctx.Builder.CommitLine(); - } + // 🔎 ···(dot) NOTE: This is a note\n + ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); + ctx.Builder.Append(Character.Dot, Color.Grey); + ctx.Builder.AppendSpace(); + ctx.Builder.Append("NOTE: ", Color.Aqua); + ctx.Builder.Append(labelWithNote.Note); + ctx.Builder.CommitLine(); + } - if (lastLabel) - { - // 🔎 ···│\n - ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); - ctx.Builder.Append(Character.VerticalLine, Color.Grey); - ctx.Builder.CommitLine(); - } + if (lastLabel) + { + // 🔎 ···│\n + ctx.Builder.AppendSpaces(ctx.LineNumberWidth + ctx.LeftPadding); + ctx.Builder.Append(Character.VerticalLine, Color.Grey); + ctx.Builder.CommitLine(); } } @@ -166,6 +186,18 @@ private static void RenderText(DiagnosticContext ctx, TextLine line, IReadOnlyLi ctx.Builder.CommitLine(); } + private static void RenderContextLine(DiagnosticContext ctx, TextLine line) + { + RenderMargin(ctx, line, true); + ctx.Builder.AppendSpace(); + foreach (var character in line.Text) + { + ctx.Builder.Append(character, null, Decoration.Dim); + } + + ctx.Builder.CommitLine(); + } + private static void RenderVerticalLines( DiagnosticContext ctx, TextLine line, IReadOnlyList labels, int row) diff --git a/src/Errata/Rendering/LabelInfo.cs b/src/Errata/Rendering/LabelInfo.cs index a267d03..4a1f1c2 100644 --- a/src/Errata/Rendering/LabelInfo.cs +++ b/src/Errata/Rendering/LabelInfo.cs @@ -34,6 +34,7 @@ internal sealed class LabelInfo public string Message => Label.Message; public string? Note => Label.Note; public int Priority => Label.Priority; + public int ContextLines => Label.ContextLines; public LabelInfo( string sourceId, TextSpan sourceSpan, Label label, diff --git a/src/Errata/Rendering/ReportContext.cs b/src/Errata/Rendering/ReportContext.cs index 3495649..f40087c 100644 --- a/src/Errata/Rendering/ReportContext.cs +++ b/src/Errata/Rendering/ReportContext.cs @@ -16,6 +16,7 @@ internal sealed class ReportContext public bool LeftPadding { get; } public bool PropagateExceptions { get; } public bool ExcludeStackTrace { get; } + public int DefaultContextLines { get; } public ReportContext(IAnsiConsole console, ISourceRepository repository, ReportSettings? settings) { @@ -30,11 +31,12 @@ public ReportContext(IAnsiConsole console, ISourceRepository repository, ReportS LeftPadding = _settings.LeftPadding; PropagateExceptions = _settings.PropagateExceptions; ExcludeStackTrace = _settings.ExcludeStackTrace; + DefaultContextLines = _settings.DefaultContextLines; } public DiagnosticContext CreateDiagnosticContext(Diagnostic diagnostic) { - var groups = SourceGroupCollection.CreateFromLabels(_repository, diagnostic.Labels); + var groups = SourceGroupCollection.CreateFromLabels(_repository, diagnostic.Labels, DefaultContextLines); return new DiagnosticContext(this, diagnostic, groups); } } diff --git a/src/Errata/Rendering/SourceGroup.cs b/src/Errata/Rendering/SourceGroup.cs index 7bbfe43..f40d00d 100644 --- a/src/Errata/Rendering/SourceGroup.cs +++ b/src/Errata/Rendering/SourceGroup.cs @@ -22,13 +22,46 @@ internal sealed class SourceGroup /// public IReadOnlyList Labels { get; } + /// + /// Gets the set of line indices that are context-only lines (no labels). + /// + private HashSet ContextLineIndices { get; } + public SourceGroup(Source source, IEnumerable labels) { Source = source; Labels = new List(labels); + ContextLineIndices = new HashSet(); var min = Labels.Min(info => info.SourceSpan.Start); var max = Labels.Max(label => label.SourceSpan.End); + + // Calculate context lines for each label + foreach (var label in Labels) + { + if (label.ContextLines <= 0) + { + continue; + } + + // Find the line index where this label starts + var labelLineIndex = source.GetLineOffset(label.SourceSpan.Start).LineIndex; + + // Add context lines above this label + for (var i = 1; i <= label.ContextLines; i++) + { + var contextLineIndex = labelLineIndex - i; + if (contextLineIndex >= 0) + { + ContextLineIndices.Add(contextLineIndex); + + // Expand the span to include this context line + var contextLine = source.Lines[contextLineIndex]; + min = Math.Min(min, contextLine.Offset); + } + } + } + Span = new TextSpan(min, max); } @@ -53,4 +86,9 @@ public IReadOnlyList GetLabelsForLine(TextLine line) .OrderBy(l => l.Priority) .ThenBy(l => l.Columns.Start)); } + + public bool IsContextLine(int lineIndex) + { + return ContextLineIndices.Contains(lineIndex); + } } diff --git a/src/Errata/Rendering/SourceGroupCollection.cs b/src/Errata/Rendering/SourceGroupCollection.cs index 218185d..ad3e2a0 100644 --- a/src/Errata/Rendering/SourceGroupCollection.cs +++ b/src/Errata/Rendering/SourceGroupCollection.cs @@ -11,7 +11,7 @@ public SourceGroupCollection(IEnumerable collection) { } - public static SourceGroupCollection CreateFromLabels(ISourceRepository repository, List