Repository: mgechev/revive
Pull request #979 (2024-04-13 10:39:38)

adds `comments-density` rule
This PR adds a new rule `comments-density`.
The rule is inspired on the SonarQube rule on the [_comment lines density_ metric](https://docs.sonarsource.com/sonarqube/latest/user-guide/metric-definitions/)

The default argument value of the rule is `0`, i.e. the rule is de-facto deactivated, to avoid polluting analysis of those using `enableAllRules = true` in the configuration.


denisvmedia: I'd happily review it tomorrow evening (PRG time).

Thanks for the PR! 

Patch:
diff --git a/README.md b/README.md
index 1aed0e4..797283a 100644
--- a/README.md
+++ b/README.md
@@ -539,6 +539,7 @@ List of all available rules. The rules ported from `golint` are left unchanged a
 | [`enforce-slice-style`](./RULES_DESCRIPTIONS.md#enforce-slice-style) |  string (defaults to "any")  |  Enforces consistent usage of `make([]type, 0)` or `[]type{}` for slice initialization. Does not affect `make(map[type]type, non_zero_len, or_non_zero_cap)` constructions. |    no    |  no   |
 | [`enforce-repeated-arg-type-style`](./RULES_DESCRIPTIONS.md#enforce-repeated-arg-type-style) |  string (defaults to "any")  |  Enforces consistent style for repeated argument and/or return value types. |    no    |  no   |
 | [`max-control-nesting`](./RULES_DESCRIPTIONS.md#max-control-nesting) |  int (defaults to 5)  | Sets restriction for maximum nesting of control structures. |    no    |  no   |
+| [`comments-density`](./RULES_DESCRIPTIONS.md#comments-density) |  int (defaults to 0)  | Enforces a minumum comment / code relation |    no    |  no   |
 
 
 ## Configurable rules
diff --git a/RULES_DESCRIPTIONS.md b/RULES_DESCRIPTIONS.md
index 8657524..33cd070 100644
--- a/RULES_DESCRIPTIONS.md
+++ b/RULES_DESCRIPTIONS.md
@@ -14,6 +14,7 @@ List of all available rules.
   - [call-to-gc](#call-to-gc)
   - [cognitive-complexity](#cognitive-complexity)
   - [comment-spacings](#comment-spacings)
+  - [comments-density](#comment-spacings)
   - [confusing-naming](#confusing-naming)
   - [confusing-results](#confusing-results)
   - [constant-logical-expr](#constant-logical-expr)
@@ -183,15 +184,29 @@ _Description_: Spots comments of the form:
 _Configuration_: ([]string) list of exceptions. For example, to accept comments of the form
 ```go
 //mypragma: activate something
+//+optional
 ```
-You need to add `"mypragma"` in the configuration
+You need to add both `"mypragma:"` and `"+optional"` in the configuration
 
 Example:
 
 ```toml
 [rule.comment-spacings]
-  arguments =["mypragma","otherpragma"]
+  arguments =["mypragma:", "+optional"]
 ```
+## comments-density
+
+_Description_: Spots files not respecting a minimum value for the [_comments lines density_](https://docs.sonarsource.com/sonarqube/latest/user-guide/metric-definitions/) metric = _comment lines / (lines of code + comment lines) * 100_
+
+_Configuration_: (int) the minimum expected comments lines density.
+
+Example:
+
+```toml
+[rule.comments-density]
+  arguments =[15]
+```
+
 ## confusing-naming
 
 _Description_: Methods or fields of `struct` that have names different only by capitalization could be confusing.
diff --git a/config/config.go b/config/config.go
index 50a2b89..fed1d19 100644
--- a/config/config.go
+++ b/config/config.go
@@ -95,6 +95,7 @@ var allRules = append([]lint.Rule{
 	&rule.EnforceRepeatedArgTypeStyleRule{},
 	&rule.EnforceSliceStyleRule{},
 	&rule.MaxControlNestingRule{},
+	&rule.CommentsDensityRule{},
 }, defaultRules...)
 
 var allFormatters = []lint.Formatter{
diff --git a/rule/comment-spacings.go b/rule/comment-spacings.go
index 2b8240c..0a29b96 100644
--- a/rule/comment-spacings.go
+++ b/rule/comment-spacings.go
@@ -31,7 +31,7 @@ func (r *CommentSpacingsRule) configure(arguments lint.Arguments) {
 			if !ok {
 				panic(fmt.Sprintf("invalid argument %v for %s; expected string but got %T", arg, r.Name(), arg))
 			}
-			r.allowList = append(r.allowList, `//`+allow+`:`)
+			r.allowList = append(r.allowList, `//`+allow)
 		}
 	}
 }
diff --git a/rule/comments-density.go b/rule/comments-density.go
new file mode 100644
index 0000000..9dd192c
--- /dev/null
+++ b/rule/comments-density.go
@@ -0,0 +1,95 @@
+package rule
+
+import (
+	"fmt"
+	"go/ast"
+	"strings"
+	"sync"
+
+	"github.com/mgechev/revive/lint"
+)
+
+// CommentsDensityRule lints given else constructs.
+type CommentsDensityRule struct {
+	minumumCommentsDensity int64
+	configured             bool
+	sync.Mutex
+}
+
+const defaultMinimumCommentsPercentage = 0
+
+func (r *CommentsDensityRule) configure(arguments lint.Arguments) {
+	r.Lock()
+	defer r.Unlock()
+
+	if r.configured {
+		return
+	}
+
+	r.configured = true
+
+	if len(arguments) < 1 {
+		r.minumumCommentsDensity = defaultMinimumCommentsPercentage
+		return
+	}
+
+	var ok bool
+	r.minumumCommentsDensity, ok = arguments[0].(int64)
+	if !ok {
+		panic(fmt.Sprintf("invalid argument for %q rule: argument should be an int, got %T", r.Name(), arguments[0]))
+	}
+}
+
+// Apply applies the rule to given file.
+func (r *CommentsDensityRule) Apply(file *lint.File, arguments lint.Arguments) []lint.Failure {
+	r.configure(arguments)
+
+	commentsLines := countDocLines(file.AST.Comments)
+	statementsCount := countStatements(file.AST)
+	density := (float32(commentsLines) / float32(statementsCount+commentsLines)) * 100
+
+	if density < float32(r.minumumCommentsDensity) {
+		return []lint.Failure{
+			{
+				Node:       file.AST,
+				Confidence: 1,
+				Failure:    fmt.Sprintf("the file has a comment density of %2.f%% (%d comment lines for %d code lines) but expected a minimum of %d%%", density, commentsLines, statementsCount, r.minumumCommentsDensity),
+			},
+		}
+	}
+
+	return nil
+}
+
+// Name returns the rule name.
+func (*CommentsDensityRule) Name() string {
+	return "comments-density"
+}
+
+// countStatements counts the number of program statements in the given AST.
+func countStatements(node ast.Node) int {
+	counter := 0
+
+	ast.Inspect(node, func(n ast.Node) bool {
+		switch n.(type) {
+		case *ast.ExprStmt, *ast.AssignStmt, *ast.ReturnStmt, *ast.GoStmt, *ast.DeferStmt,
+			*ast.BranchStmt, *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt,
+			*ast.SelectStmt, *ast.ForStmt, *ast.RangeStmt, *ast.CaseClause, *ast.CommClause,
+			*ast.DeclStmt, *ast.FuncDecl:
+			counter++
+		}
+		return true
+	})
+
+	return counter
+}
+
+func countDocLines(comments []*ast.CommentGroup) int {
+	acc := 0
+	for _, c := range comments {
+		lines := strings.Split(c.Text(), "\n")
+		acc += len(lines) - 1
+	}
+
+	return acc
+}


Test patch:
diff --git a/test/comment-spacings_test.go b/test/comment-spacings_test.go
index 9a430b0..e7abeb7 100644
--- a/test/comment-spacings_test.go
+++ b/test/comment-spacings_test.go
@@ -9,6 +9,6 @@ import (
 
 func TestCommentSpacings(t *testing.T) {
 	testRule(t, "comment-spacings", &rule.CommentSpacingsRule{}, &lint.RuleConfig{
-		Arguments: []any{"myOwnDirective"}},
+		Arguments: []any{"myOwnDirective:", "+optional"}},
 	)
 }
diff --git a/test/comments-density_test.go b/test/comments-density_test.go
new file mode 100644
index 0000000..3d15f1f
--- /dev/null
+++ b/test/comments-density_test.go
@@ -0,0 +1,20 @@
+package test
+
+import (
+	"testing"
+
+	"github.com/mgechev/revive/lint"
+	"github.com/mgechev/revive/rule"
+)
+
+func TestCommentsDensity(t *testing.T) {
+	testRule(t, "comments-density-1", &rule.CommentsDensityRule{}, &lint.RuleConfig{
+		Arguments: []any{int64(60)},
+	})
+
+	testRule(t, "comments-density-2", &rule.CommentsDensityRule{}, &lint.RuleConfig{
+		Arguments: []any{int64(90)},
+	})
+
+	testRule(t, "comments-density-3", &rule.CommentsDensityRule{}, &lint.RuleConfig{})
+}
diff --git a/testdata/comment-spacings.go b/testdata/comment-spacings.go
index 6d94137..92c832c 100644
--- a/testdata/comment-spacings.go
+++ b/testdata/comment-spacings.go
@@ -42,3 +42,8 @@ Should be valid
 
 //nolint:staticcheck // nolint should be in the default list of acceptable comments.
 var b string
+
+type c struct {
+	//+optional
+	d *int `json:"d,omitempty"`
+}
diff --git a/testdata/comments-density-1.go b/testdata/comments-density-1.go
new file mode 100644
index 0000000..fa96d31
--- /dev/null
+++ b/testdata/comments-density-1.go
@@ -0,0 +1,9 @@
+package fixtures // MATCH /the file has a comment density of 57% (4 comment lines for 3 code lines) but expected a minimum of 60%/
+
+// func contains banned characters Ω // authorized banned chars in comment
+func cd1() error {
+	// the var
+	var charσhid string
+	/* the return */
+	return nil
+}
diff --git a/testdata/comments-density-2.go b/testdata/comments-density-2.go
new file mode 100644
index 0000000..85fe725
--- /dev/null
+++ b/testdata/comments-density-2.go
@@ -0,0 +1,34 @@
+package fixtures // MATCH /the file has a comment density of 19% (5 comment lines for 21 code lines) but expected a minimum of 90%/
+
+// datarace is function
+func datarace() (r int, c char) {
+	for _, p := range []int{1, 2} {
+		go func() {
+			print(r)
+			print(p)
+		}()
+		for i, p1 := range []int{1, 2} {
+			a := p1
+			go func() {
+				print(r)
+				print(p)
+				print(p1)
+				print(a)
+				print(i)
+			}()
+			print(i)
+			print(p)
+			go func() {
+				_ = c
+			}()
+		}
+		print(p1)
+	}
+	/* Goroutines
+	are
+	awesome */
+	go func() {
+		print(r)
+	}()
+	print(r)
+}
diff --git a/testdata/comments-density-3.go b/testdata/comments-density-3.go
new file mode 100644
index 0000000..5609cce
--- /dev/null
+++ b/testdata/comments-density-3.go
@@ -0,0 +1,34 @@
+package fixtures
+
+// datarace is function
+func datarace() (r int, c char) {
+	for _, p := range []int{1, 2} {
+		go func() {
+			print(r)
+			print(p)
+		}()
+		for i, p1 := range []int{1, 2} {
+			a := p1
+			go func() {
+				print(r)
+				print(p)
+				print(p1)
+				print(a)
+				print(i)
+			}()
+			print(i)
+			print(p)
+			go func() {
+				_ = c
+			}()
+		}
+		print(p1)
+	}
+	/* Goroutines
+	are
+	awesome */
+	go func() {
+		print(r)
+	}()
+	print(r)
+}