Repository: mgechev/revive
Pull request #1155 (2024-12-02 19:06:51)

deep-exit: ignore testable examples
I propose to ignore testable [example functions](https://go.dev/blog/examples) in the `deep-exit` rule. There is nothing wrong with using `log.Fatal` or `log.Fatalf` in examples.

See these examples where using `log.Fatal` is normal:

- https://github.com/golang/go/blob/e5489a34ca2c31608821d3ac4ec07892fb6a2272/src/embed/example_test.go#L21
- https://github.com/google/go-github/blob/c6856bc9e29aeba0999a959f7a892098b18ff965/github/examples_test.go#L103
- https://github.com/golang/go/blob/e5489a34ca2c31608821d3ac4ec07892fb6a2272/src/image/decode_example_test.go#L28

ccoVeille: Thanks for this. I faced the exact same issue a few weeks ago

Patch:
diff --git a/rule/deep_exit.go b/rule/deep_exit.go
index 8441b50..bc09604 100644
--- a/rule/deep_exit.go
+++ b/rule/deep_exit.go
@@ -3,6 +3,9 @@ package rule
 import (
 	"fmt"
 	"go/ast"
+	"strings"
+	"unicode"
+	"unicode/utf8"
 
 	"github.com/mgechev/revive/lint"
 )
@@ -76,5 +79,32 @@ func (w *lintDeepExit) Visit(node ast.Node) ast.Visitor {
 func (w *lintDeepExit) mustIgnore(fd *ast.FuncDecl) bool {
 	fn := fd.Name.Name
 
-	return fn == "init" || fn == "main" || (w.isTestFile && fn == "TestMain")
+	return fn == "init" || fn == "main" || w.isTestMain(fd) || w.isTestExample(fd)
+}
+
+func (w *lintDeepExit) isTestMain(fd *ast.FuncDecl) bool {
+	return w.isTestFile && fd.Name.Name == "TestMain"
+}
+
+// isTestExample returns true if the function is a testable example function.
+// See https://go.dev/blog/examples#examples-are-tests for more information.
+//
+// Inspired by https://github.com/golang/go/blob/go1.23.0/src/go/doc/example.go#L72-L77
+func (w *lintDeepExit) isTestExample(fd *ast.FuncDecl) bool {
+	if !w.isTestFile {
+		return false
+	}
+	name := fd.Name.Name
+	const prefix = "Example"
+	if !strings.HasPrefix(name, prefix) {
+		return false
+	}
+	if len(name) == len(prefix) { // "Example" is a package level example
+		return len(fd.Type.Params.List) == 0
+	}
+	r, _ := utf8.DecodeRuneInString(name[len(prefix):])
+	if unicode.IsLower(r) {
+		return false
+	}
+	return len(fd.Type.Params.List) == 0
 }


Test patch:
diff --git a/rule/deep_exit_test.go b/rule/deep_exit_test.go
new file mode 100644
index 0000000..a2e7d93
--- /dev/null
+++ b/rule/deep_exit_test.go
@@ -0,0 +1,82 @@
+package rule
+
+import (
+	"go/ast"
+	"go/parser"
+	"go/token"
+	"slices"
+	"testing"
+)
+
+func TestLintDeepExit_isTestExample(t *testing.T) {
+	tests := []struct {
+		name       string
+		funcDecl   string
+		isTestFile bool
+		want       bool
+	}{
+		{
+			name:       "Package level example",
+			funcDecl:   "func Example() {}",
+			isTestFile: true,
+			want:       true,
+		},
+		{
+			name:       "Function example",
+			funcDecl:   "func ExampleFunction() {}",
+			isTestFile: true,
+			want:       true,
+		},
+		{
+			name:       "Method example",
+			funcDecl:   "func ExampleType_Method() {}",
+			isTestFile: true,
+			want:       true,
+		},
+		{
+			name:       "Wrong example function",
+			funcDecl:   "func Examplemethod() {}",
+			isTestFile: true,
+			want:       false,
+		},
+		{
+			name:       "Not an example",
+			funcDecl:   "func NotAnExample() {}",
+			isTestFile: true,
+			want:       false,
+		},
+		{
+			name:       "Example with parameters",
+			funcDecl:   "func ExampleWithParams(a int) {}",
+			isTestFile: true,
+			want:       false,
+		},
+		{
+			name:       "Not a test file",
+			funcDecl:   "func Example() {}",
+			isTestFile: false,
+			want:       false,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			fs := token.NewFileSet()
+			node, err := parser.ParseFile(fs, "", "package main\n"+tt.funcDecl, parser.AllErrors)
+			if err != nil {
+				t.Fatal(err)
+			}
+			idx := slices.IndexFunc(node.Decls, func(decl ast.Decl) bool {
+				_, ok := decl.(*ast.FuncDecl)
+				return ok
+			})
+			fd := node.Decls[idx].(*ast.FuncDecl)
+
+			w := &lintDeepExit{isTestFile: tt.isTestFile}
+			got := w.isTestExample(fd)
+			if got != tt.want {
+				t.Errorf("isTestExample() = %v, want %v", got, tt.want)
+			}
+		})
+	}
+}
diff --git a/testdata/deep_exit_test.go b/testdata/deep_exit_test.go
index 697643a..2477113 100644
--- a/testdata/deep_exit_test.go
+++ b/testdata/deep_exit_test.go
@@ -1,6 +1,8 @@
 package fixtures
 
 import (
+	"errors"
+	"log"
 	"os"
 	"testing"
 )
@@ -9,3 +11,23 @@ func TestMain(m *testing.M) {
 	// call flag.Parse() here if TestMain uses flags
 	os.Exit(m.Run())
 }
+
+// Testable package level example
+func Example() {
+	log.Fatal(errors.New("example"))
+}
+
+// Testable function example
+func ExampleFoo() {
+	log.Fatal(errors.New("example"))
+}
+
+// Testable method example
+func ExampleBar_Qux() {
+	log.Fatal(errors.New("example"))
+}
+
+// Not an example because it has an argument
+func ExampleBar(int) {
+	log.Fatal(errors.New("example")) // MATCH /calls to log.Fatal only in main() or init() functions/
+}