Repository: reviewdog/reviewdog
Pull request #1806 (2024-06-30 11:11:05)

Implement rdjson and rdjsonl reporters
It reports output to stdout in rdjson/rdjsonl format.

It also changes the default behavior of -filter-mode and -diff command
behavior for local (local/rdjson/rdjsonl) reporters.
If -filter-mode is not provided (-filter-mode=default) and -diff flag is
not provided, reviewdog automatically set -filter-mode=nofilter.

- [x] Updated Unreleased section in [CHANGELOG](https://github.com/reviewdog/reviewdog/blob/master/CHANGELOG.md) or it's not notable changes.




Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d406fd4b..9c1ad0af 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### :sparkles: Release Note <!-- optional -->
 
 ### :rocket: Enhancements
-- ...
+- [#1806](https://github.com/reviewdog/reviewdog/pull/1806) Add -reporter=rdjson/rdjsonl which outputs rdjson/rdjsonl format to stdout. It also changes the default behavior of -diff and -filter-mode for local reporters. If -filter-mode is not provided (-filter-mode=default) and -diff flag is not provided, reviewdog automatically set -filter-mode=nofilter.
 
 ### :bug: Fixes
 - ...
diff --git a/cmd/reviewdog/main.go b/cmd/reviewdog/main.go
index c47de487..24d2f0dd 100644
--- a/cmd/reviewdog/main.go
+++ b/cmd/reviewdog/main.go
@@ -65,7 +65,7 @@ type option struct {
 }
 
 const (
-	diffCmdDoc    = `diff command (e.g. "git diff") for local reporter. Do not use --relative flag for git command.`
+	diffCmdDoc    = `diff command (e.g. "git diff") for local reporters. Do not use --relative flag for git command.`
 	diffStripDoc  = "strip NUM leading components from diff file names (equivalent to 'patch -p') (default is 1 for git diff)"
 	efmsDoc       = `list of supported machine-readable format and errorformat (https://github.com/reviewdog/errorformat)`
 	fDoc          = `format name (run -list to see supported format name) for input. It's also used as tool name in review comment if -name is empty`
@@ -89,10 +89,16 @@ const (
 		"nofilter"
 			Do not filter any results.
 `
-	reporterDoc = `reporter of reviewdog results. (local, github-check, github-pr-check, github-pr-review, gitlab-mr-discussion, gitlab-mr-commit, gitea-pr-review)
+	reporterDoc = `reporter of reviewdog results.
 	"local" (default)
 		Report results to stdout.
 
+	"rdjson"
+		Report results to stdout in rdjson format.
+
+	"rdjsonl"
+		Report results to stdout in rdjsonl format.
+
 	"github-check"
 		Report results to GitHub Check. It works both for Pull Requests and commits.
 		For Pull Request, you can see report results in GitHub PullRequest Check
@@ -406,15 +412,25 @@ func run(r io.Reader, w io.Writer, opt *option) error {
 		cs = reviewdog.MultiCommentService(gs, cs)
 		ds = gs
 	case "local":
-		if opt.diffCmd == "" && opt.filterMode == filter.ModeNoFilter {
-			ds = &reviewdog.EmptyDiff{}
-		} else {
-			d, err := diffService(opt.diffCmd, opt.diffStrip)
-			if err != nil {
-				return err
-			}
-			ds = d
+		d, err := localDiffService(opt)
+		if err != nil {
+			return err
+		}
+		ds = d
+	case "rdjson":
+		d, err := localDiffService(opt)
+		if err != nil {
+			return err
 		}
+		ds = d
+		cs = reviewdog.NewRDJSONCommentWriter(w, toolName(opt))
+	case "rdjsonl":
+		d, err := localDiffService(opt)
+		if err != nil {
+			return err
+		}
+		ds = d
+		cs = reviewdog.NewRDJSONLCommentWriter(w)
 	}
 
 	if isProject {
@@ -934,3 +950,11 @@ func getRunnersList(opt *option, conf *project.Config) []string {
 
 	return []string{}
 }
+
+func localDiffService(opt *option) (reviewdog.DiffService, error) {
+	if (opt.diffCmd == "" && opt.filterMode == filter.ModeDefault) || opt.filterMode == filter.ModeNoFilter {
+		opt.filterMode = filter.ModeNoFilter
+		return &reviewdog.EmptyDiff{}, nil
+	}
+	return diffService(opt.diffCmd, opt.diffStrip)
+}
diff --git a/comment_iowriter.go b/comment_iowriter.go
index 2a48308a..9e043068 100644
--- a/comment_iowriter.go
+++ b/comment_iowriter.go
@@ -4,6 +4,9 @@ import (
 	"context"
 	"fmt"
 	"io"
+
+	"github.com/reviewdog/reviewdog/proto/rdf"
+	"google.golang.org/protobuf/encoding/protojson"
 )
 
 var _ CommentService = &RawCommentWriter{}
@@ -56,3 +59,86 @@ func (mc *UnifiedCommentWriter) Post(_ context.Context, c *Comment) error {
 	_, err := fmt.Fprintln(mc.w, s)
 	return err
 }
+
+var _ CommentService = &RDJSONLCommentWriter{}
+
+// RDJSONLCommentWriter
+type RDJSONLCommentWriter struct {
+	w io.Writer
+}
+
+func NewRDJSONLCommentWriter(w io.Writer) *RDJSONLCommentWriter {
+	return &RDJSONLCommentWriter{w: w}
+}
+
+func (cw *RDJSONLCommentWriter) Post(_ context.Context, c *Comment) error {
+	if c.ToolName != "" && c.Result.Diagnostic.GetSource().GetName() == "" {
+		c.Result.Diagnostic.Source = &rdf.Source{
+			Name: c.ToolName,
+		}
+	}
+	b, err := protojson.MarshalOptions{
+		UseProtoNames:     true,
+		UseEnumNumbers:    false,
+		Multiline:         false,
+		EmitUnpopulated:   false,
+		EmitDefaultValues: false,
+	}.Marshal(c.Result.Diagnostic)
+	if err != nil {
+		return err
+	}
+	if _, err = cw.w.Write(b); err != nil {
+		return err
+	}
+	if _, err := cw.w.Write([]byte("\n")); err != nil {
+		return err
+	}
+	return nil
+}
+
+var _ CommentService = &RDJSONCommentWriter{}
+
+// RDJSONCommentWriter
+type RDJSONCommentWriter struct {
+	w        io.Writer
+	comments []*Comment
+	toolName string
+}
+
+func NewRDJSONCommentWriter(w io.Writer, toolName string) *RDJSONCommentWriter {
+	return &RDJSONCommentWriter{w: w, toolName: toolName}
+}
+
+func (cw *RDJSONCommentWriter) Post(_ context.Context, c *Comment) error {
+	cw.comments = append(cw.comments, c)
+	return nil
+}
+
+func (cw *RDJSONCommentWriter) Flush(_ context.Context) error {
+	result := &rdf.DiagnosticResult{
+		Source: &rdf.Source{
+			Name: cw.toolName,
+		},
+		Diagnostics: make([]*rdf.Diagnostic, 0, len(cw.comments)),
+	}
+	for _, c := range cw.comments {
+		result.Diagnostics = append(result.Diagnostics, c.Result.Diagnostic)
+	}
+	b, err := protojson.MarshalOptions{
+		UseProtoNames:     true,
+		UseEnumNumbers:    false,
+		Multiline:         true,
+		EmitUnpopulated:   false,
+		EmitDefaultValues: false,
+	}.Marshal(result)
+	if err != nil {
+		return err
+	}
+	if _, err = cw.w.Write(b); err != nil {
+		return err
+	}
+	if _, err := cw.w.Write([]byte("\n")); err != nil {
+		return err
+	}
+	return nil
+}


Test patch:
diff --git a/cmd/reviewdog/main_test.go b/cmd/reviewdog/main_test.go
index fb2c2daa..ae4b04b4 100644
--- a/cmd/reviewdog/main_test.go
+++ b/cmd/reviewdog/main_test.go
@@ -83,10 +83,11 @@ func TestRun_local_nofilter(t *testing.T) {
 	)
 
 	opt := &option{
-		diffCmd:   "", // empty
-		efms:      strslice([]string{`%f(%l,%c): %m`}),
-		diffStrip: 0,
-		reporter:  "local",
+		diffCmd:    "", // empty
+		efms:       strslice([]string{`%f(%l,%c): %m`}),
+		diffStrip:  0,
+		reporter:   "local",
+		filterMode: filter.ModeAdded,
 	}
 
 	stdout := new(bytes.Buffer)
diff --git a/comment_iowriter_test.go b/comment_iowriter_test.go
index 4f46b8c4..1ab06fde 100644
--- a/comment_iowriter_test.go
+++ b/comment_iowriter_test.go
@@ -3,6 +3,7 @@ package reviewdog
 import (
 	"bytes"
 	"context"
+	"regexp"
 	"strings"
 	"testing"
 
@@ -93,3 +94,168 @@ line2`,
 		}
 	}
 }
+
+func TestRDJSONLCommentWriter_Post(t *testing.T) {
+	tests := []struct {
+		in   *Comment
+		want string
+	}{
+		{
+			in: &Comment{
+				Result: &filter.FilteredDiagnostic{
+					Diagnostic: &rdf.Diagnostic{
+						Location: &rdf.Location{Path: "/path/to/file"},
+						Message:  "message",
+					},
+				},
+				ToolName: "tool name",
+			},
+			want: `{"message":"message","location":{"path":"/path/to/file"},"source":{"name":"tool name"}}`,
+		},
+		{
+			in: &Comment{
+				Result: &filter.FilteredDiagnostic{
+					Diagnostic: &rdf.Diagnostic{
+						Location: &rdf.Location{
+							Path: "/path/to/file",
+							Range: &rdf.Range{Start: &rdf.Position{
+								Column: 14,
+							}},
+						},
+						Message: "message",
+					},
+				},
+			},
+			want: `{"message":"message","location":{"path":"/path/to/file","range":{"start":{"column":14}}}}`,
+		},
+		{
+			in: &Comment{
+				Result: &filter.FilteredDiagnostic{
+					Diagnostic: &rdf.Diagnostic{
+						Location: &rdf.Location{
+							Path: "/path/to/file",
+							Range: &rdf.Range{Start: &rdf.Position{
+								Column: 14,
+							}},
+						},
+						Message: "message",
+						Source: &rdf.Source{
+							Name: "tool name in Diagnostic",
+							Url:  "tool url",
+						},
+					},
+				},
+			},
+			want: `{"message":"message","location":{"path":"/path/to/file","range":{"start":{"column":14}}},"source":{"name":"tool name in Diagnostic","url":"tool url"}}`,
+		},
+	}
+	for _, tt := range tests {
+		buf := new(bytes.Buffer)
+		cw := NewRDJSONLCommentWriter(buf)
+		if err := cw.Post(context.Background(), tt.in); err != nil {
+			t.Error(err)
+			continue
+		}
+		if got := strings.ReplaceAll(strings.Trim(buf.String(), "\n"), `, "`, `,"`); got != tt.want {
+			t.Errorf("RDJSONLCommentWriter.Post(%v) = \n%v\nwant:\n%v", tt.in, got, tt.want)
+		}
+	}
+}
+
+func TestRDJSONCommentWriter_Post(t *testing.T) {
+	comments := []*Comment{
+		{
+			Result: &filter.FilteredDiagnostic{
+				Diagnostic: &rdf.Diagnostic{
+					Location: &rdf.Location{Path: "/path/to/file"},
+					Message:  "message",
+				},
+			},
+			ToolName: "tool name",
+		},
+		{
+			Result: &filter.FilteredDiagnostic{
+				Diagnostic: &rdf.Diagnostic{
+					Location: &rdf.Location{
+						Path: "/path/to/file",
+						Range: &rdf.Range{Start: &rdf.Position{
+							Column: 14,
+						}},
+					},
+					Message: "message",
+				},
+			},
+		},
+		{
+			Result: &filter.FilteredDiagnostic{
+				Diagnostic: &rdf.Diagnostic{
+					Location: &rdf.Location{
+						Path: "/path/to/file",
+						Range: &rdf.Range{Start: &rdf.Position{
+							Column: 14,
+						}},
+					},
+					Message: "message",
+					Source: &rdf.Source{
+						Name: "tool name in Diagnostic",
+						Url:  "tool url",
+					},
+				},
+			},
+		},
+	}
+	buf := new(bytes.Buffer)
+	cw := NewRDJSONCommentWriter(buf, "tool name [constructor]")
+	for _, c := range comments {
+		if err := cw.Post(context.Background(), c); err != nil {
+			t.Error(err)
+		}
+	}
+	if err := cw.Flush(context.Background()); err != nil {
+		t.Error(err)
+	}
+	want := `{
+  "diagnostics": [
+    {
+      "message": "message",
+      "location": {
+        "path": "/path/to/file"
+      }
+    },
+    {
+      "message": "message",
+      "location": {
+        "path": "/path/to/file",
+        "range": {
+          "start": {
+            "column": 14
+          }
+        }
+      }
+    },
+    {
+      "message": "message",
+      "location": {
+        "path": "/path/to/file",
+        "range": {
+          "start": {
+            "column": 14
+          }
+        }
+      },
+      "source": {
+        "name": "tool name in Diagnostic",
+        "url": "tool url"
+      }
+    }
+  ],
+  "source": {
+    "name": "tool name [constructor]"
+  }
+}`
+	re := regexp.MustCompile(`:\s+`)
+	got := re.ReplaceAllString(strings.TrimSpace(buf.String()), ":")
+	if got != re.ReplaceAllString(strings.TrimSpace(want), ":") {
+		t.Errorf("got\n%v\nwant:\n%v", got, want)
+	}
+}