github.com/vanstinator/golangci-lint@v0.0.0-20240223191551-cc572f00d9d1/pkg/golinters/nolintlint/nolintlint.go (about)

     1  // Package nolintlint provides a linter to ensure that all //nolint directives are followed by explanations
     2  package nolintlint
     3  
     4  import (
     5  	"fmt"
     6  	"go/ast"
     7  	"go/token"
     8  	"regexp"
     9  	"strings"
    10  	"unicode"
    11  
    12  	"github.com/vanstinator/golangci-lint/pkg/result"
    13  )
    14  
    15  type BaseIssue struct {
    16  	fullDirective                     string
    17  	directiveWithOptionalLeadingSpace string
    18  	position                          token.Position
    19  	replacement                       *result.Replacement
    20  }
    21  
    22  func (b BaseIssue) Position() token.Position {
    23  	return b.position
    24  }
    25  
    26  func (b BaseIssue) Replacement() *result.Replacement {
    27  	return b.replacement
    28  }
    29  
    30  type ExtraLeadingSpace struct {
    31  	BaseIssue
    32  }
    33  
    34  func (i ExtraLeadingSpace) Details() string {
    35  	return fmt.Sprintf("directive `%s` should not have more than one leading space", i.fullDirective)
    36  }
    37  
    38  func (i ExtraLeadingSpace) String() string { return toString(i) }
    39  
    40  type NotMachine struct {
    41  	BaseIssue
    42  }
    43  
    44  func (i NotMachine) Details() string {
    45  	expected := i.fullDirective[:2] + strings.TrimLeftFunc(i.fullDirective[2:], unicode.IsSpace)
    46  	return fmt.Sprintf("directive `%s` should be written without leading space as `%s`",
    47  		i.fullDirective, expected)
    48  }
    49  
    50  func (i NotMachine) String() string { return toString(i) }
    51  
    52  type NotSpecific struct {
    53  	BaseIssue
    54  }
    55  
    56  func (i NotSpecific) Details() string {
    57  	return fmt.Sprintf("directive `%s` should mention specific linter such as `%s:my-linter`",
    58  		i.fullDirective, i.directiveWithOptionalLeadingSpace)
    59  }
    60  
    61  func (i NotSpecific) String() string { return toString(i) }
    62  
    63  type ParseError struct {
    64  	BaseIssue
    65  }
    66  
    67  func (i ParseError) Details() string {
    68  	return fmt.Sprintf("directive `%s` should match `%s[:<comma-separated-linters>] [// <explanation>]`",
    69  		i.fullDirective,
    70  		i.directiveWithOptionalLeadingSpace)
    71  }
    72  
    73  func (i ParseError) String() string { return toString(i) }
    74  
    75  type NoExplanation struct {
    76  	BaseIssue
    77  	fullDirectiveWithoutExplanation string
    78  }
    79  
    80  func (i NoExplanation) Details() string {
    81  	return fmt.Sprintf("directive `%s` should provide explanation such as `%s // this is why`",
    82  		i.fullDirective, i.fullDirectiveWithoutExplanation)
    83  }
    84  
    85  func (i NoExplanation) String() string { return toString(i) }
    86  
    87  type UnusedCandidate struct {
    88  	BaseIssue
    89  	ExpectedLinter string
    90  }
    91  
    92  func (i UnusedCandidate) Details() string {
    93  	details := fmt.Sprintf("directive `%s` is unused", i.fullDirective)
    94  	if i.ExpectedLinter != "" {
    95  		details += fmt.Sprintf(" for linter %q", i.ExpectedLinter)
    96  	}
    97  	return details
    98  }
    99  
   100  func (i UnusedCandidate) String() string { return toString(i) }
   101  
   102  func toString(i Issue) string {
   103  	return fmt.Sprintf("%s at %s", i.Details(), i.Position())
   104  }
   105  
   106  type Issue interface {
   107  	Details() string
   108  	Position() token.Position
   109  	String() string
   110  	Replacement() *result.Replacement
   111  }
   112  
   113  type Needs uint
   114  
   115  const (
   116  	NeedsMachineOnly Needs = 1 << iota
   117  	NeedsSpecific
   118  	NeedsExplanation
   119  	NeedsUnused
   120  	NeedsAll = NeedsMachineOnly | NeedsSpecific | NeedsExplanation
   121  )
   122  
   123  var commentPattern = regexp.MustCompile(`^//\s*(nolint)(:\s*[\w-]+\s*(?:,\s*[\w-]+\s*)*)?\b`)
   124  
   125  // matches a complete nolint directive
   126  var fullDirectivePattern = regexp.MustCompile(`^//\s*nolint(?::(\s*[\w-]+\s*(?:,\s*[\w-]+\s*)*))?\s*(//.*)?\s*\n?$`)
   127  
   128  type Linter struct {
   129  	needs           Needs // indicates which linter checks to perform
   130  	excludeByLinter map[string]bool
   131  }
   132  
   133  // NewLinter creates a linter that enforces that the provided directives fulfill the provided requirements
   134  func NewLinter(needs Needs, excludes []string) (*Linter, error) {
   135  	excludeByName := make(map[string]bool)
   136  	for _, e := range excludes {
   137  		excludeByName[e] = true
   138  	}
   139  
   140  	return &Linter{
   141  		needs:           needs | NeedsMachineOnly,
   142  		excludeByLinter: excludeByName,
   143  	}, nil
   144  }
   145  
   146  var leadingSpacePattern = regexp.MustCompile(`^//(\s*)`)
   147  var trailingBlankExplanation = regexp.MustCompile(`\s*(//\s*)?$`)
   148  
   149  //nolint:funlen,gocyclo
   150  func (l Linter) Run(fset *token.FileSet, nodes ...ast.Node) ([]Issue, error) {
   151  	var issues []Issue
   152  
   153  	for _, node := range nodes {
   154  		file, ok := node.(*ast.File)
   155  		if !ok {
   156  			continue
   157  		}
   158  
   159  		for _, c := range file.Comments {
   160  			for _, comment := range c.List {
   161  				if !commentPattern.MatchString(comment.Text) {
   162  					continue
   163  				}
   164  
   165  				// check for a space between the "//" and the directive
   166  				leadingSpaceMatches := leadingSpacePattern.FindStringSubmatch(comment.Text)
   167  
   168  				var leadingSpace string
   169  				if len(leadingSpaceMatches) > 0 {
   170  					leadingSpace = leadingSpaceMatches[1]
   171  				}
   172  
   173  				directiveWithOptionalLeadingSpace := "//"
   174  				if leadingSpace != "" {
   175  					directiveWithOptionalLeadingSpace += " "
   176  				}
   177  
   178  				split := strings.Split(strings.SplitN(comment.Text, ":", 2)[0], "//")
   179  				directiveWithOptionalLeadingSpace += strings.TrimSpace(split[1])
   180  
   181  				pos := fset.Position(comment.Pos())
   182  				end := fset.Position(comment.End())
   183  
   184  				base := BaseIssue{
   185  					fullDirective:                     comment.Text,
   186  					directiveWithOptionalLeadingSpace: directiveWithOptionalLeadingSpace,
   187  					position:                          pos,
   188  				}
   189  
   190  				// check for, report and eliminate leading spaces, so we can check for other issues
   191  				if leadingSpace != "" {
   192  					removeWhitespace := &result.Replacement{
   193  						Inline: &result.InlineFix{
   194  							StartCol:  pos.Column + 1,
   195  							Length:    len(leadingSpace),
   196  							NewString: "",
   197  						},
   198  					}
   199  					if (l.needs & NeedsMachineOnly) != 0 {
   200  						issue := NotMachine{BaseIssue: base}
   201  						issue.BaseIssue.replacement = removeWhitespace
   202  						issues = append(issues, issue)
   203  					} else if len(leadingSpace) > 1 {
   204  						issue := ExtraLeadingSpace{BaseIssue: base}
   205  						issue.BaseIssue.replacement = removeWhitespace
   206  						issue.BaseIssue.replacement.Inline.NewString = " " // assume a single space was intended
   207  						issues = append(issues, issue)
   208  					}
   209  				}
   210  
   211  				fullMatches := fullDirectivePattern.FindStringSubmatch(comment.Text)
   212  				if len(fullMatches) == 0 {
   213  					issues = append(issues, ParseError{BaseIssue: base})
   214  					continue
   215  				}
   216  
   217  				lintersText, explanation := fullMatches[1], fullMatches[2]
   218  
   219  				var linters []string
   220  				if lintersText != "" && !strings.HasPrefix(lintersText, "all") {
   221  					lls := strings.Split(lintersText, ",")
   222  					linters = make([]string, 0, len(lls))
   223  					rangeStart := (pos.Column - 1) + len("//") + len(leadingSpace) + len("nolint:")
   224  					for i, ll := range lls {
   225  						rangeEnd := rangeStart + len(ll)
   226  						if i < len(lls)-1 {
   227  							rangeEnd++ // include trailing comma
   228  						}
   229  						trimmedLinterName := strings.TrimSpace(ll)
   230  						if trimmedLinterName != "" {
   231  							linters = append(linters, trimmedLinterName)
   232  						}
   233  						rangeStart = rangeEnd
   234  					}
   235  				}
   236  
   237  				if (l.needs & NeedsSpecific) != 0 {
   238  					if len(linters) == 0 {
   239  						issues = append(issues, NotSpecific{BaseIssue: base})
   240  					}
   241  				}
   242  
   243  				// when detecting unused directives, we send all the directives through and filter them out in the nolint processor
   244  				if (l.needs & NeedsUnused) != 0 {
   245  					removeNolintCompletely := &result.Replacement{
   246  						Inline: &result.InlineFix{
   247  							StartCol:  pos.Column - 1,
   248  							Length:    end.Column - pos.Column,
   249  							NewString: "",
   250  						},
   251  					}
   252  
   253  					if len(linters) == 0 {
   254  						issue := UnusedCandidate{BaseIssue: base}
   255  						issue.replacement = removeNolintCompletely
   256  						issues = append(issues, issue)
   257  					} else {
   258  						for _, linter := range linters {
   259  							issue := UnusedCandidate{BaseIssue: base, ExpectedLinter: linter}
   260  							// only offer replacement if there is a single linter
   261  							// because of issues around commas and the possibility of all
   262  							// linters being removed
   263  							if len(linters) == 1 {
   264  								issue.replacement = removeNolintCompletely
   265  							}
   266  							issues = append(issues, issue)
   267  						}
   268  					}
   269  				}
   270  
   271  				if (l.needs&NeedsExplanation) != 0 && (explanation == "" || strings.TrimSpace(explanation) == "//") {
   272  					needsExplanation := len(linters) == 0 // if no linters are mentioned, we must have explanation
   273  					// otherwise, check if we are excluding all the mentioned linters
   274  					for _, ll := range linters {
   275  						if !l.excludeByLinter[ll] { // if a linter does require explanation
   276  							needsExplanation = true
   277  							break
   278  						}
   279  					}
   280  
   281  					if needsExplanation {
   282  						fullDirectiveWithoutExplanation := trailingBlankExplanation.ReplaceAllString(comment.Text, "")
   283  						issues = append(issues, NoExplanation{
   284  							BaseIssue:                       base,
   285  							fullDirectiveWithoutExplanation: fullDirectiveWithoutExplanation,
   286  						})
   287  					}
   288  				}
   289  			}
   290  		}
   291  	}
   292  
   293  	return issues, nil
   294  }