Repository: editorconfig-checker/editorconfig-checker (go)

Issue:
it would be nice if editorconfig-checker could collect instances of the same issues found in adjacent lines into a single report line
I've just put a bunch of generated json documents into one of our repositories, but forgot to either ignore them or adjust their indentation to fit our editorconfig.

However this lead to me getting flooded with about 10k lines of reports that the indentation was incorrect.

I think a better output format would collect consecutive issues by not outputting each line by line, but rather turning consecutive lines having the same issue into a range. Although I can imagine that this naive approach would fail in cases where each line has two errors reported (they would ping-pong, interrupting each other's consecutiveness).

Pull request description:
feat: consolidate adjacent error messages
This changes the human output format to collect multiple identical error
messages on consecutive lines into one error message that list both the
starting and ending line.

This is still missing a properly written testcase, I so far only tested
with

    make
    (cd testfiles; ../bin/ec multiple-wrong-indentations.txt)


So far the output for the newly included testfile is this:

    multiple-wrong-indentations.txt:
            2-4: Wrong amount of left-padding spaces(want multiple of 10)
            8-9: Wrong amount of left-padding spaces(want multiple of 10)
    6 errors found
    1 files checked

However there are still a few things that annoy me:
1. The summary of how many errors are present is now incorrect, since the `error.GetErrorCount()` is called by `main()`, before any consolidation happened.
However one should not hoist the consolidation up into `main()`, since it must only happen for the human output (or the eventual github action format mentioned in #245).
My first instinct would be to let `error.PrintErrors()` handle writing that summary as well, but technically that would add a side effect for `error.TestPrintErrors()`.

2. I have no idea how to work with golang's testing infrastructure. I'd roughly guess that one would have to make PrintErrors() side effect free by collecting the lines to be printed and returning them, letting the caller handle the actual printing, but being then able to validate the output lines in a testcase.

I'd be really happy for any feedback, while programming for years, this is maybe my third time touching golang - which is quite a different beast compared to Perl. So especially if any I did is not idiomatic or hard to understand, please tell me! (aka: `perlcritic --brutal`)

Patch:
diff --git a/.gitignore b/.gitignore
index 48ec4f0..adf17e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,4 @@ editorconfig-checker.test
 coverage.txt
 result-bin
 path
+node_modules
diff --git a/README.md b/README.md
index 10115c2..b4b687b 100644
--- a/README.md
+++ b/README.md
@@ -138,9 +138,15 @@ If you run this tool from a normal directory it will check all files which are t
 
 The following output formats are supported:
 
-- **default**: Plain text, human readable output.
-- **gcc**: GCC compatible output. Useful for editors that support compiling and showing syntax errors.
-           <file>:<line>:<column>: <type>: <message>
+- **default**: Plain text, human readable output.<br/>
+      ```text
+      <file>:
+        <startingLine>-<endLine>: <message>
+      ```
+- **gcc**: GCC compatible output. Useful for editors that support compiling and showing syntax errors. <br/>
+      `<file>:<line>:<column>: <type>: <message>`
+- **github-actions**: The format used by GitHub Actions <br/>
+      `::error file=<file>,line=<startingLine>,endLine=<endingLine>::<message>`
 
 ## Configuration
 
diff --git a/cmd/editorconfig-checker/main.go b/cmd/editorconfig-checker/main.go
index 72961fd..2c01da9 100644
--- a/cmd/editorconfig-checker/main.go
+++ b/cmd/editorconfig-checker/main.go
@@ -39,8 +39,8 @@ func init() {
 	flag.BoolVar(&c.ShowVersion, "version", false, "print the version number")
 	flag.BoolVar(&c.Help, "help", false, "print the help")
 	flag.BoolVar(&c.Help, "h", false, "print the help")
-	flag.StringVar(&c.Format, "format", "default", "specify the output format: default, gcc")
-	flag.StringVar(&c.Format, "f", "default", "specify the output format: default, gcc")
+	flag.StringVar(&c.Format, "format", "default", "specify the output format: default, gcc, github-actions")
+	flag.StringVar(&c.Format, "f", "default", "specify the output format: default, gcc, github-actions")
 	flag.BoolVar(&c.Verbose, "verbose", false, "print debugging information")
 	flag.BoolVar(&c.Verbose, "v", false, "print debugging information")
 	flag.BoolVar(&c.Debug, "debug", false, "print debugging information")
@@ -122,10 +122,11 @@ func main() {
 	}
 
 	errors := validation.ProcessValidation(filePaths, config)
-	errorCount := error.GetErrorCount(errors)
 
+	formattedErrors := error.FormatErrors(errors, config)
+	errorCount := len(formattedErrors)
 	if errorCount != 0 {
-		error.PrintErrors(errors, config)
+		config.Logger.PrintLogMessages(formattedErrors)
 		config.Logger.Error(fmt.Sprintf("\n%d errors found", errorCount))
 	}
 
diff --git a/go.mod b/go.mod
index a788962..bdb2eff 100644
--- a/go.mod
+++ b/go.mod
@@ -9,8 +9,22 @@ require (
 	golang.org/x/text v0.17.0
 )
 
+require (
+	github.com/gkampitakis/ciinfo v0.3.0 // indirect
+	github.com/gkampitakis/go-diff v1.3.2 // indirect
+	github.com/kr/pretty v0.3.1 // indirect
+	github.com/kr/text v0.2.0 // indirect
+	github.com/maruel/natural v1.1.1 // indirect
+	github.com/rogpeppe/go-internal v1.12.0 // indirect
+	github.com/tidwall/gjson v1.17.0 // indirect
+	github.com/tidwall/match v1.1.1 // indirect
+	github.com/tidwall/pretty v1.2.1 // indirect
+	github.com/tidwall/sjson v1.2.5 // indirect
+)
+
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
+	github.com/gkampitakis/go-snaps v0.5.7
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/mod v0.17.0 // indirect
 	golang.org/x/net v0.27.0 // indirect
diff --git a/go.sum b/go.sum
index d63e0b3..bbc9704 100644
--- a/go.sum
+++ b/go.sum
@@ -1,17 +1,44 @@
 github.com/baulk/chardet v0.1.0 h1:6/r5nPMikB9OG1Njs10VfVHZTDMFH6BdybHPISpfUVA=
 github.com/baulk/chardet v0.1.0/go.mod h1:0ibN6068qswel5Hv54U7GNJUU57njfzPJrLIq7Y8xas=
+github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/editorconfig/editorconfig-core-go/v2 v2.6.2 h1:dKG8sc7n321deIVRcQtwlMNoBEra7j0qQ8RwxO8RN0w=
 github.com/editorconfig/editorconfig-core-go/v2 v2.6.2/go.mod h1:7dvD3GCm7eBw53xZ/lsiq72LqobdMg3ITbMBxnmJmqY=
 github.com/gabriel-vasile/mimetype v1.4.5 h1:J7wGKdGu33ocBOhGy0z653k/lFKLFDPJMG8Gql0kxn4=
 github.com/gabriel-vasile/mimetype v1.4.5/go.mod h1:ibHel+/kbxn9x2407k1izTA1S81ku1z/DlgOW2QE0M4=
+github.com/gkampitakis/ciinfo v0.3.0 h1:gWZlOC2+RYYttL0hBqcoQhM7h1qNkVqvRCV1fOvpAv8=
+github.com/gkampitakis/ciinfo v0.3.0/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.7 h1:uVGjHR4t4pPHU944udMx7VKHpwepZXmvDMF+yDmI0rg=
+github.com/gkampitakis/go-snaps v0.5.7/go.mod h1:ZABkO14uCuVxBHAXAfKG+bqNz+aa1bGPAg8jkI0Nk8Y=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
+github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
+github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
+github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
 github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/gjson v1.17.0 h1:/Jocvlh98kcTfpN2+JzGQWQcqrPQwDrVEMApx/M5ZwM=
+github.com/tidwall/gjson v1.17.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
 golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
diff --git a/pkg/error/error.go b/pkg/error/error.go
index 294edf0..a680863 100644
--- a/pkg/error/error.go
+++ b/pkg/error/error.go
@@ -6,12 +6,14 @@ import (
 
 	"github.com/editorconfig-checker/editorconfig-checker/v3/pkg/config"
 	"github.com/editorconfig-checker/editorconfig-checker/v3/pkg/files"
+	"github.com/editorconfig-checker/editorconfig-checker/v3/pkg/logger"
 )
 
 // ValidationError represents one validation error
 type ValidationError struct {
-	LineNumber int
-	Message    error
+	LineNumber                    int
+	Message                       error
+	AdditionalIdenticalErrorCount int
 }
 
 // ValidationErrors represents which errors occurred in a file
@@ -20,6 +22,20 @@ type ValidationErrors struct {
 	Errors   []ValidationError
 }
 
+func (error1 *ValidationError) Equal(error2 ValidationError) bool {
+	if error1.Message.Error() != error2.Message.Error() {
+		return false
+	}
+	if error1.LineNumber != error2.LineNumber {
+		return false
+	}
+	if error1.AdditionalIdenticalErrorCount != error2.AdditionalIdenticalErrorCount {
+		return false
+	}
+	return true
+
+}
+
 // GetErrorCount returns the amount of errors
 func GetErrorCount(errors []ValidationErrors) int {
 	var errorCount = 0
@@ -31,17 +47,61 @@ func GetErrorCount(errors []ValidationErrors) int {
 	return errorCount
 }
 
-// PrintErrors prints the errors to the console
-func PrintErrors(errors []ValidationErrors, config config.Config) {
+func ConsolidateErrors(errors []ValidationError, config config.Config) []ValidationError {
+	var lineLessErrors []ValidationError
+	var errorsWithLines []ValidationError
+
+	// filter the errors, so we do not need to care about LineNumber == -1 in the loop below
+	for _, singleError := range errors {
+		if singleError.LineNumber == -1 {
+			lineLessErrors = append(lineLessErrors, singleError)
+		} else {
+			errorsWithLines = append(errorsWithLines, singleError)
+		}
+	}
+
+	config.Logger.Debug("sorted errors: %d with line number -1, %d with a line number", len(lineLessErrors), len(errorsWithLines))
+
+	var consolidatedErrors []ValidationError
+
+	// scan through the errors
+	for i := 0; i < len(errorsWithLines); i++ {
+		thisError := errorsWithLines[i]
+		config.Logger.Debug("investigating error %d(%s)", i, thisError.Message)
+		// scan through the errors after the current one
+		for j, nextError := range errorsWithLines[i+1:] {
+			config.Logger.Debug("comparing against error %d(%s)", j, nextError.Message)
+			if nextError.Message.Error() == thisError.Message.Error() && nextError.LineNumber == thisError.LineNumber+thisError.AdditionalIdenticalErrorCount+1 {
+				thisError.AdditionalIdenticalErrorCount++ // keep track of how many consecutive lines we've seen
+				i = i + j + 1                             // make sure the outer loop jumps over the consecutive errors we just found
+			} else {
+				break // if they are different errors we can stop comparing messages
+			}
+		}
+
+		consolidatedErrors = append(consolidatedErrors, thisError)
+	}
+
+	return append(lineLessErrors, consolidatedErrors...)
+}
+
+// FormatErrors prints the errors to the console
+func FormatErrors(errors []ValidationErrors, config config.Config) []logger.LogMessage {
+	var formatted_errors []logger.LogMessage
 	for _, fileErrors := range errors {
 		if len(fileErrors.Errors) > 0 {
 			relativeFilePath, err := files.GetRelativePath(fileErrors.FilePath)
 
 			if err != nil {
-				config.Logger.Error(err.Error())
+				formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: err.Error()})
 				continue
 			}
 
+			// for these formats the errors need to be consolidated first.
+			if config.Format != "gcc" {
+				fileErrors.Errors = ConsolidateErrors(fileErrors.Errors, config)
+			}
+
 			if config.Format == "gcc" {
 				// gcc: A format mimicking the error format from GCC.
 				for _, singleError := range fileErrors.Errors {
@@ -49,19 +109,37 @@ func PrintErrors(errors []ValidationErrors, config config.Config) {
 					if singleError.LineNumber > 0 {
 						lineNo = singleError.LineNumber
 					}
-					config.Logger.Error(fmt.Sprintf("%s:%d:%d: %s: %s", relativeFilePath, lineNo, 0, "error", singleError.Message))
+					formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("%s:%d:%d: %s: %s", relativeFilePath, lineNo, 0, "error", singleError.Message)})
+				}
+			} else if config.Format == "github-actions" {
+				// github-actions: A format dedicated for usage in Github Actions
+				for _, singleError := range fileErrors.Errors {
+					if singleError.LineNumber != -1 {
+						if singleError.AdditionalIdenticalErrorCount != 0 {
+							formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("::error file=%s,line=%d,endLine=%d::%s", relativeFilePath, singleError.LineNumber, singleError.LineNumber+singleError.AdditionalIdenticalErrorCount, singleError.Message)})
+						} else {
+							formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("::error file=%s,line=%d::%s", relativeFilePath, singleError.LineNumber, singleError.Message)})
+						}
+					} else {
+						formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("::error file=%s::%s", relativeFilePath, singleError.Message)})
+					}
 				}
 			} else {
 				// default: A human readable text format.
-				config.Logger.Warning(fmt.Sprintf("%s:", relativeFilePath))
+				formatted_errors = append(formatted_errors, logger.LogMessage{Level: "warning", Message: fmt.Sprintf("%s:", relativeFilePath)})
 				for _, singleError := range fileErrors.Errors {
 					if singleError.LineNumber != -1 {
-						config.Logger.Error(fmt.Sprintf("\t%d: %s", singleError.LineNumber, singleError.Message))
+						if singleError.AdditionalIdenticalErrorCount != 0 {
+							formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("\t%d-%d: %s", singleError.LineNumber, singleError.LineNumber+singleError.AdditionalIdenticalErrorCount, singleError.Message)})
+						} else {
+							formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("\t%d: %s", singleError.LineNumber, singleError.Message)})
+						}
 					} else {
-						config.Logger.Error(fmt.Sprintf("\t%s", singleError.Message))
+						formatted_errors = append(formatted_errors, logger.LogMessage{Level: "error", Message: fmt.Sprintf("\t%s", singleError.Message)})
 					}
 				}
 			}
 		}
 	}
+	return formatted_errors
 }
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index f98d8dc..7ebeccd 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -13,6 +13,11 @@ const (
 	RESET  = "\x1b[33;0m"
 )
 
+type LogMessage struct {
+	Level   string
+	Message string
+}
+
 // Logger struct
 type Logger struct {
 	Verbosee bool
@@ -46,6 +51,27 @@ func (l Logger) Warning(format string, a ...interface{}) {
 	}
 }
 
+func (l Logger) PrintLogMessage(message LogMessage) {
+	switch message.Level {
+	case "error":
+		l.Error(message.Message)
+	case "warning":
+		l.Warning(message.Message)
+	case "debug":
+		l.Debug(message.Message)
+	case "verbose":
+		l.Verbose(message.Message)
+	default:
+		l.Output(message.Message)
+	}
+}
+
+func (l Logger) PrintLogMessages(messages []LogMessage) {
+	for _, message := range messages {
+		l.PrintLogMessage(message)
+	}
+}
+
 // Warning prints a warning message to Stdout in yellow
 func Warning(format string, a ...interface{}) {
 	message := fmt.Sprintf(format, a...)


Test patch:
diff --git a/pkg/error/__snapshots__/pathseparator-backslash/error_test.snap b/pkg/error/__snapshots__/pathseparator-backslash/error_test.snap
new file mode 100644
index 0000000..3f3bc0f
--- /dev/null
+++ b/pkg/error/__snapshots__/pathseparator-backslash/error_test.snap
@@ -0,0 +1,47 @@
+
+[TestFormatErrors - 1]
+[]logger.LogMessage{
+    {Level:"warning", Message:"/proc/cpuinfo:"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"/proc/cpuinfoNOT:"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"some/other/path:"},
+    {Level:"error", Message:"\tWRONG"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"some/file/with/consecutive/errors:"},
+    {Level:"error", Message:"\tfile-level error"},
+    {Level:"error", Message:"\t1-2: message kind one"},
+    {Level:"error", Message:"\t4: message kind one"},
+    {Level:"error", Message:"\t5: message kind two"},
+    {Level:"error", Message:"\t6: message kind one"},
+}
+---
+
+[TestFormatErrors - 2]
+[]logger.LogMessage{
+    {Level:"error", Message:"/proc/cpuinfo:1:0: error: WRONG"},
+    {Level:"error", Message:"/proc/cpuinfoNOT:1:0: error: WRONG"},
+    {Level:"error", Message:"some/other/path:1:0: error: WRONG"},
+    {Level:"error", Message:"some/other/path:0:0: error: WRONG"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:1:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:2:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:4:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:5:0: error: message kind two"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:6:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:0:0: error: file-level error"},
+}
+---
+
+[TestFormatErrors - 3]
+[]logger.LogMessage{
+    {Level:"error", Message:"::error file=/proc/cpuinfo,line=1::WRONG"},
+    {Level:"error", Message:"::error file=/proc/cpuinfoNOT,line=1::WRONG"},
+    {Level:"error", Message:"::error file=some/other/path::WRONG"},
+    {Level:"error", Message:"::error file=some/other/path,line=1::WRONG"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors::file-level error"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=1,endLine=2::message kind one"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=4::message kind one"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=5::message kind two"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=6::message kind one"},
+}
+---
diff --git a/pkg/error/__snapshots__/pathseparator-slash/error_test.snap b/pkg/error/__snapshots__/pathseparator-slash/error_test.snap
new file mode 100755
index 0000000..7df71a4
--- /dev/null
+++ b/pkg/error/__snapshots__/pathseparator-slash/error_test.snap
@@ -0,0 +1,47 @@
+
+[TestFormatErrors - 1]
+[]logger.LogMessage{
+    {Level:"warning", Message:"proc/cpuinfo:"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"proc/cpuinfoNOT:"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"some/other/path:"},
+    {Level:"error", Message:"\tWRONG"},
+    {Level:"error", Message:"\t1: WRONG"},
+    {Level:"warning", Message:"some/file/with/consecutive/errors:"},
+    {Level:"error", Message:"\tfile-level error"},
+    {Level:"error", Message:"\t1-2: message kind one"},
+    {Level:"error", Message:"\t4: message kind one"},
+    {Level:"error", Message:"\t5: message kind two"},
+    {Level:"error", Message:"\t6: message kind one"},
+}
+---
+
+[TestFormatErrors - 2]
+[]logger.LogMessage{
+    {Level:"error", Message:"proc/cpuinfo:1:0: error: WRONG"},
+    {Level:"error", Message:"proc/cpuinfoNOT:1:0: error: WRONG"},
+    {Level:"error", Message:"some/other/path:1:0: error: WRONG"},
+    {Level:"error", Message:"some/other/path:0:0: error: WRONG"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:1:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:2:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:4:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:5:0: error: message kind two"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:6:0: error: message kind one"},
+    {Level:"error", Message:"some/file/with/consecutive/errors:0:0: error: file-level error"},
+}
+---
+
+[TestFormatErrors - 3]
+[]logger.LogMessage{
+    {Level:"error", Message:"::error file=proc/cpuinfo,line=1::WRONG"},
+    {Level:"error", Message:"::error file=proc/cpuinfoNOT,line=1::WRONG"},
+    {Level:"error", Message:"::error file=some/other/path::WRONG"},
+    {Level:"error", Message:"::error file=some/other/path,line=1::WRONG"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors::file-level error"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=1,endLine=2::message kind one"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=4::message kind one"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=5::message kind two"},
+    {Level:"error", Message:"::error file=some/file/with/consecutive/errors,line=6::message kind one"},
+}
+---
diff --git a/pkg/error/error_test.go b/pkg/error/error_test.go
index 79e38d5..ba3e679 100644
--- a/pkg/error/error_test.go
+++ b/pkg/error/error_test.go
@@ -2,9 +2,13 @@ package error
 
 import (
 	"errors"
+	"os"
+	"path/filepath"
+	"slices"
 	"testing"
 
 	"github.com/editorconfig-checker/editorconfig-checker/v3/pkg/config"
+	"github.com/gkampitakis/go-snaps/snaps"
 )
 
 func TestGetErrorCount(t *testing.T) {
@@ -112,7 +116,156 @@ func TestGetErrorCount(t *testing.T) {
 	}
 }
 
-func TestPrintErrors(t *testing.T) {
+func TestValidationErrorEqual(t *testing.T) {
+	baseError := ValidationError{
+		LineNumber: -1,
+		Message:    errors.New("a message"),
+	}
+	wrongLineNumberError := ValidationError{
+		LineNumber: 2,
+		Message:    errors.New("a message"),
+	}
+	differentMessageError := ValidationError{
+		LineNumber: -1,
+		Message:    errors.New("different message"),
+	}
+	differentCountError := ValidationError{
+		LineNumber:                    -1,
+		Message:                       errors.New("a message"),
+		AdditionalIdenticalErrorCount: 1,
+	}
+	if !baseError.Equal(baseError) {
+		t.Error("failed to detect an error being equal to itself")
+	}
+	if baseError.Equal(wrongLineNumberError) {
+		t.Error("failed to detect a difference in the LineNumber")
+	}
+	if baseError.Equal(differentMessageError) {
+		t.Error("failed to detect a difference in the Message")
+	}
+	if baseError.Equal(differentCountError) {
+		t.Error("failed to detect a difference in the ConsequtiveCount")
+	}
+}
+
+func TestConsolidateErrors(t *testing.T) {
+	input := []ValidationError{
+		// two messages that become one block
+		{LineNumber: 1, Message: errors.New("message kind one")},
+		{LineNumber: 2, Message: errors.New("message kind one")},
+		// one message with a good line between it and the last bad line, but repeating the message
+		{LineNumber: 4, Message: errors.New("message kind one")},
+		// one message that breaks the continuousness of line 4 to line 6
+		{LineNumber: 5, Message: errors.New("message kind two")},
+		{LineNumber: 6, Message: errors.New("message kind one")},
+		// one message without a line number, that will become sorted to the top
+		{LineNumber: -1, Message: errors.New("file-level error")},
+	}
+
+	expected := []ValidationError{
+		{LineNumber: -1, Message: errors.New("file-level error")},
+		{LineNumber: 1, AdditionalIdenticalErrorCount: 1, Message: errors.New("message kind one")},
+		{LineNumber: 4, Message: errors.New("message kind one")},
+		{LineNumber: 5, Message: errors.New("message kind two")},
+		{LineNumber: 6, Message: errors.New("message kind one")},
+	}
+
+	actual := ConsolidateErrors(input, config.Config{})
+
+	if !slices.EqualFunc(expected, actual, func(e1 ValidationError, e2 ValidationError) bool { return e1.Equal(e2) }) {
+		t.Log("consolidation expectation          :", expected)
+		t.Log("consolidation actual returned value:", actual)
+		t.Error("returned list of validation errors deviated from the expected set")
+	}
+}
+
+func TestConsolidatingInterleavedErrors(t *testing.T) {
+	t.Skip("Consolidating non-consecutive errors is not supported by the current implementation")
+	/*
+		an assumption made about the possible future implement:
+		it is implied that the implementation will sort the error messages by their error message
+		If the implementation does not sort, this test will randomly fail when the implementation uses a map
+	*/
+	input := []ValidationError{
+		{LineNumber: 1, Message: errors.New("message kind 2")},
+
+		{LineNumber: 2, Message: errors.New("message kind 1")},
+		{LineNumber: 2, Message: errors.New("message kind 2")},
+
+		{LineNumber: 3, Message: errors.New("message kind 1")},
+		{LineNumber: 3, Message: errors.New("message kind 2")},
+		{LineNumber: 3, Message: errors.New("message kind 3")},
+
+		{LineNumber: 4, Message: errors.New("message kind 4")},
+
+		{LineNumber: 5, Message: errors.New("message kind 1")},
+
+		{LineNumber: -1, Message: errors.New("file-level error")},
+	}
+
+	expected := []ValidationError{
+		{LineNumber: -1, Message: errors.New("file-level error")},
+		{LineNumber: 1, AdditionalIdenticalErrorCount: 2, Message: errors.New("message kind 2")},
+		{LineNumber: 2, AdditionalIdenticalErrorCount: 1, Message: errors.New("message kind 1")},
+		{LineNumber: 3, AdditionalIdenticalErrorCount: 0, Message: errors.New("message kind 3")},
+		{LineNumber: 4, AdditionalIdenticalErrorCount: 1, Message: errors.New("message kind 4")},
+		{LineNumber: 5, AdditionalIdenticalErrorCount: 0, Message: errors.New("message kind 1")},
+	}
+
+	actual := ConsolidateErrors(input, config.Config{})
+
+	if !slices.EqualFunc(expected, actual, func(e1 ValidationError, e2 ValidationError) bool { return e1.Equal(e2) }) {
+		t.Log("consolidation expectation          :", expected)
+		t.Log("consolidation actual returned value:", actual)
+		t.Error("returned list of validation errors deviated from the expected set")
+	}
+}
+
+func TestFormatErrors(t *testing.T) {
+
+	/*
+		why change directory?
+		The relative path conversion done by FormatErrors() changes how absolute paths
+		are displayed, depending on in which directory the test is run in.
+
+		When this codebase uses golang 1.23.X (what ever version contains commit 79ca434)
+		This entire section can be replaced with a call to t.Chdir("/")
+		(the cleanup is then done automatically)
+	*/
+	startingDir, err := os.Getwd()
+	if err != nil {
+		t.Fatalf("Could not obtain current working directory: %s", err)
+	}
+	t.Cleanup(func() {
+		if err := os.Chdir(startingDir); err != nil {
+			t.Fatalf("Could not restore old working directory %s: %s", startingDir, err)
+		}
+	})
+	if err := os.Chdir("/"); err != nil {
+		t.Fatalf("Could not chdir to /: %s", err)
+	}
+
+	/*
+		why care about the path separators?
+
+		The absolute paths in the testdata below come out differently, depending on the path separator
+		used by the system that runs the tests.
+
+		This leads to the problem, that one cannot verify a snapshot taken under Linux when using Windows
+		and vice versa, unless the tests are differentiated by the path separator.
+	*/
+	var safePathSep string
+	if os.PathSeparator == '/' {
+		safePathSep = "slash"
+	} else if os.PathSeparator == '\\' {
+		safePathSep = "backslash"
+	} else {
+		t.Fatal("current path separator is unexpected - please fix test to handle this path separator")
+	}
+	s := snaps.WithConfig(
+		snaps.Dir(filepath.Join("__snapshots__", "pathseparator-"+safePathSep)),
+	)
+
 	input := []ValidationErrors{
 		{
 			FilePath: "some/path",
@@ -149,12 +302,35 @@ func TestPrintErrors(t *testing.T) {
 				},
 			},
 		},
+		{
+			FilePath: "some/file/with/consecutive/errors",
+			Errors: []ValidationError{
+				{LineNumber: 1, Message: errors.New("message kind one")},
+				{LineNumber: 2, Message: errors.New("message kind one")},
+				{LineNumber: 4, Message: errors.New("message kind one")},
+				{LineNumber: 5, Message: errors.New("message kind two")},
+				{LineNumber: 6, Message: errors.New("message kind one")},
+				{LineNumber: -1, Message: errors.New("file-level error")},
+			},
+		},
 	}
 
 	// wannabe test
 	config1 := config.Config{}
-	PrintErrors(input, config1)
+	s.MatchSnapshot(t, FormatErrors(input, config1))
 
 	config2 := config.Config{Format: "gcc"}
-	PrintErrors(input, config2)
+	s.MatchSnapshot(t, FormatErrors(input, config2))
+
+	config3 := config.Config{Format: "github-actions"}
+	s.MatchSnapshot(t, FormatErrors(input, config3))
+}
+
+func TestMain(m *testing.M) {
+	v := m.Run()
+
+	// After all tests have run `go-snaps` will sort snapshots
+	snaps.Clean(m, snaps.CleanOpts{Sort: true})
+
+	os.Exit(v)
 }
diff --git a/pkg/logger/logger_test.go b/pkg/logger/logger_test.go
index cf3efec..25bc47c 100644
--- a/pkg/logger/logger_test.go
+++ b/pkg/logger/logger_test.go
@@ -64,3 +64,17 @@ func TestPrintColor(t *testing.T) {
 func TestPrint(t *testing.T) {
 	Print("Hello")
 }
+
+func TestPrintLogMessage(t *testing.T) {
+	logger := Logger{}
+
+	messages := []LogMessage{
+		{Level: "debug", Message: "debug message"},
+		{Level: "verbose", Message: "verbose message"},
+		{Level: "warning", Message: "warning message"},
+		{Level: "error", Message: "error message"},
+		{Level: "output", Message: "normal message"},
+	}
+
+	logger.PrintLogMessages(messages)
+}