Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Resolves #70: add stricter comment directive scan #72

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,52 @@ import (
)

const (
ignoreComment = "//exhaustive:ignore"
enforceComment = "//exhaustive:enforce"
ignoreDefaultCaseRequiredComment = "//exhaustive:ignore-default-case-required"
enforceDefaultCaseRequiredComment = "//exhaustive:enforce-default-case-required"
exhaustiveComment = "//exhaustive:"
ignoreComment = "ignore"
enforceComment = "enforce"
ignoreDefaultCaseRequiredComment = "ignore-default-case-required"
enforceDefaultCaseRequiredComment = "enforce-default-case-required"
)

func hasCommentPrefix(comments []*ast.CommentGroup, comment string) bool {
for _, c := range comments {
for _, cc := range c.List {
if strings.HasPrefix(cc.Text, comment) {
return true
type directive int64

const (
ignoreDirective = 1 << iota
enforceDirective
ignoreDefaultCaseRequiredDirective
enforceDefaultCaseRequiredDirective
)

type directiveSet int64

func parseDirectiveSet(commentGroups []*ast.CommentGroup) (out directiveSet) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Rename to parseDirectives. The "set" portion of the function name is a detail about the return type that is not necessary here.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
func parseDirectiveSet(commentGroups []*ast.CommentGroup) (out directiveSet) {
func parseDirectiveSet(commentGroups []*ast.CommentGroup) directiveSet {

Minor: Remove the name for the return parameter in the function declaration. The name does not add value here.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mostly to avoid the var out directiveSet declaration but fair enough

for _, commentGroup := range commentGroups {
for _, comment := range commentGroup.List {
commentLine := comment.Text
if !strings.HasPrefix(commentLine, exhaustiveComment) {
continue
}
directive := commentLine[len(exhaustiveComment):]
if whiteSpaceIndex := strings.IndexAny(directive, " \t"); whiteSpaceIndex != -1 {
directive = directive[:whiteSpaceIndex]
}
switch directive {
case ignoreComment:
out |= ignoreDirective
case enforceComment:
out |= enforceDirective
case ignoreDefaultCaseRequiredComment:
out |= ignoreDefaultCaseRequiredDirective
case enforceDefaultCaseRequiredComment:
out |= enforceDefaultCaseRequiredDirective
}
}
}
return false
return
}

func (d directiveSet) hasDirective(directive directive) bool {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: Shorten the method name to has. The shorter name is sufficiently clear, because checking for the presence of a directive is the apparent operation on this type.

It also avoids stutter in call sites.

directives.hasDirective(enforceDirective)

vs.

directives.has(enforceDirective)

return int64(d)&int64(directive) != 0
}

func fileCommentMap(fset *token.FileSet, file *ast.File) ast.CommentMap {
Expand Down
93 changes: 93 additions & 0 deletions comment_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package exhaustive

import (
"go/ast"
"go/parser"
"go/token"
"testing"
Expand Down Expand Up @@ -32,3 +33,95 @@ func f() {
}
})
}

func TestDirectives(t *testing.T) {
t.Run("no directives", func(t *testing.T) {
commentGroups := []*ast.CommentGroup{
{
List: []*ast.Comment{
{
Text: "//exhaustive:foo",
},
},
},
{
List: []*ast.Comment{
{
Text: "//bar:value",
},
{
Text: "Another comment",
},
},
},
}
directives := parseDirectiveSet(commentGroups)
if directives != 0 {
t.Errorf("unexpected directives: %d", directives)
}
})

t.Run("several conflicting directives", func(t *testing.T) {
commentGroups := []*ast.CommentGroup{
{
List: []*ast.Comment{
{
Text: "//exhaustive:ignore",
},
{
Text: "//exhaustive:enforce",
},
},
},
{
List: []*ast.Comment{
{
Text: "//exhaustive:ignore-default-case-required",
},
{
Text: "//exhaustive:enforce-default-case-required",
},
{
Text: "//exhaustive:ignore-default-case-required",
},
},
},
}
directives := parseDirectiveSet(commentGroups)
expected := ignoreDirective | enforceDirective |
ignoreDefaultCaseRequiredDirective | enforceDefaultCaseRequiredDirective

if directives != directiveSet(expected) {
t.Errorf("unexpected directives: %d", directives)
}
})

t.Run("directives with trailing text and surrounding non-directives", func(t *testing.T) {
commentGroups := []*ast.CommentGroup{
{
List: []*ast.Comment{
{
Text: "Nevermind this comment",
},
{
Text: "//exhaustive:ignore foo",
},
{
Text: "//exhaustive:enforce-default-case-required\tbar",
},
{
Text: "//exhaustive:ignore-default-case-garbage",
},
{
Text: "This comment doesn't matter",
},
},
},
}

directives := parseDirectiveSet(commentGroups)
if directives != ignoreDirective|enforceDefaultCaseRequiredDirective {
t.Errorf("unexpected directives: %d", directives)
}
})
}
6 changes: 4 additions & 2 deletions map.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,15 @@ func mapChecker(pass *analysis.Pass, cfg mapConfig, generated boolCache, comment
}
}

if !cfg.explicit && hasCommentPrefix(relatedComments, ignoreComment) {
directives := parseDirectiveSet(relatedComments)

if !cfg.explicit && directives.hasDirective(ignoreDirective) {
// Skip checking of this map literal due to ignore
// comment. Still return true because there may be nested
// map literals that are not to be ignored.
return true, resultIgnoreComment
}
if cfg.explicit && !hasCommentPrefix(relatedComments, enforceComment) {
if cfg.explicit && !directives.hasDirective(enforceDirective) {
return true, resultNoEnforceComment
}

Expand Down
49 changes: 7 additions & 42 deletions switch.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
"go/ast"
"go/types"
"regexp"
"strings"

"golang.org/x/tools/go/analysis"
)
Expand Down Expand Up @@ -60,41 +59,6 @@ type switchConfig struct {
ignoreType *regexp.Regexp // can be nil
}

// There are few possibilities, and often none, so we use a possibly-nil slice
func userDirectives(comments []*ast.CommentGroup) []string {
var directives []string
for _, c := range comments {
for _, cc := range c.List {
// The order matters here: we always want to check the longest first.
for _, d := range []string{
enforceDefaultCaseRequiredComment,
ignoreDefaultCaseRequiredComment,
enforceComment,
ignoreComment,
} {
if strings.HasPrefix(cc.Text, d) {
directives = append(directives, d)
// The break here is important: once we associate a comment
// with a particular (longest-possible) directive, we don't want
// to map to another!
break
}
}
}
}
return directives
}

// Can be replaced with slices.Contains with go1.21
func directivesIncludes(directives []string, d string) bool {
for _, ud := range directives {
if ud == d {
return true
}
}
return false
}

// switchChecker returns a node visitor that checks exhaustiveness of
// enum switch statements for the supplied pass, and reports
// diagnostics. The node visitor expects only *ast.SwitchStmt nodes.
Expand All @@ -118,30 +82,31 @@ func switchChecker(pass *analysis.Pass, cfg switchConfig, generated boolCache, c
sw := n.(*ast.SwitchStmt)

switchComments := comments.get(pass.Fset, file)[sw]
uDirectives := userDirectives(switchComments)
if !cfg.explicit && directivesIncludes(uDirectives, ignoreComment) {
uDirectives := parseDirectiveSet(switchComments)

if !cfg.explicit && uDirectives.hasDirective(ignoreDirective) {
// Skip checking of this switch statement due to ignore
// comment. Still return true because there may be nested
// switch statements that are not to be ignored.
return true, resultIgnoreComment
}
if cfg.explicit && !directivesIncludes(uDirectives, enforceComment) {
if cfg.explicit && !uDirectives.hasDirective(enforceDirective) {
// Skip checking of this switch statement due to missing
// enforce comment.
return true, resultNoEnforceComment
}
requireDefaultCase := cfg.defaultCaseRequired
if directivesIncludes(uDirectives, ignoreDefaultCaseRequiredComment) {
if uDirectives.hasDirective(ignoreDefaultCaseRequiredDirective) {
requireDefaultCase = false
}
if directivesIncludes(uDirectives, enforceDefaultCaseRequiredComment) {
if uDirectives.hasDirective(enforceDefaultCaseRequiredDirective) {
// We have "if" instead of "else if" here in case of conflicting ignore/enforce directives.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether it would make more sense for the analyzer to raise an error in this case. I don't foresee it happening much in practice though.

I'm more inclined to believe typos like //exhaustive:enfocre should result in errors.

Copy link
Owner

@nishanths nishanths Dec 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether it would make more sense for the analyzer to raise an error in this case. I don't foresee it happening much in practice though.

If you're referring to the scenario in which valid ignore and enforce comments are both present on the same switch statement — yes, reporting an error is preferable. Related issue

I'm more inclined to believe typos like //exhaustive:enfocre should result in errors.

I agree that reporting an error for typoed and unknown directives (e.g. "enfocre", "enforce-none") is preferable.

// In that case, because this is second, we will default to enforcing.
requireDefaultCase = true
}

if sw.Tag == nil {
return true, resultNoSwitchTag // never possible for valid Go program?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is possible, e.g.

switch {
    case err == nil:
        return result, nil
    case errors.Is(err, NotFound):
        return result, nil
    default:
        return nil, err
}

return true, resultNoSwitchTag
}

t := pass.TypesInfo.Types[sw.Tag]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
package notrequired

import "default-case-required"
import dcr "default-case-required"

func _a(t dcr.T) {
// No diagnostic because neither fDefaultCaseRequired is true
// nor the enforcement comment is present.
//exhaustive:enforce-default-case-required-typo this invalid directive should be ignored
switch t {
case dcr.A:
case dcr.B:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package required

import "default-case-required"
import dcr "default-case-required"

func _a(t dcr.T) {
// expect a diagnostic when fDefaultCaseRequired is true.
//exhaustive:ignore-default-case-required-typo This invalid directive should be ignored
switch t { // want "^missing default case in switch of type dcr.T$"
case dcr.A:
case dcr.B:
Expand All @@ -12,6 +13,7 @@ func _a(t dcr.T) {

func _b(t dcr.T) {
//exhaustive:ignore-default-case-required this is a comment showing that we can turn it off for select switches
//exhaustive:enforce-default-case-required-typo This invalid directive should be ignored
switch t {
case dcr.A:
case dcr.B:
Expand Down
Loading