feat: new custom linters system ()

This commit is contained in:
Ludovic Fernandez 2024-03-11 17:40:26 +01:00 committed by GitHub
parent f0fdea006f
commit 167204c1fd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1339 additions and 110 deletions

37
.custom-gcl.reference.yml Normal file
View file

@ -0,0 +1,37 @@
# The golangci-lint version used to build the custom binary.
# Require.
version: v1.56.2
# the name of the custom binary.
# Optional.
# Default: custom-gcl
name: custom-golangci-lint
# The directory path used to store the custom binary.
# Optional.
# Default: .
destination: ./my/path/
# The list of the plugins to integrate inside the custom binary.
plugins:
# a plugin from a Go proxy
- module: 'github.com/example/plugin3'
version: v1.2.3
# a plugin from a Go proxy (with a specific import path)
- module: 'github.com/example/plugin4'
import: 'github.com/example/plugin4/foo'
version: v1.0.0
# a plugin from local source (with absolute path)
- module: 'github.com/example/plugin2'
path: /my/local/path/plugin2
# a plugin from local source (with relative path)
- module: 'github.com/example/plugin1'
path: ./my/local/path/plugin1
# a plugin from local source (with absolute path and a specific import path)
- module: 'github.com/example/plugin2'
import: 'github.com/example/plugin4/foo'
path: /my/local/path/plugin2

4
.gitignore vendored
View file

@ -18,3 +18,7 @@
/vendor/
coverage.out
coverage.xml
/custom-golangci-lint
/custom-gcl
.custom-gcl.yml
.custom-gcl.yaml

View file

@ -2481,6 +2481,10 @@ linters-settings:
custom:
# Each custom linter should have a unique name.
example:
# The plugin type.
# It can be `goplugin` or `module`.
# Default: goplugin
type: module
# The path to the plugin *.so. Can be absolute or local.
# Required for each custom linter.
path: /path/to/example.so

View file

@ -46,6 +46,9 @@ linters-settings:
- whyNoLint
gocyclo:
min-complexity: 15
godox:
keywords:
- FIXME
gofmt:
rewrite-rules:
- pattern: 'interface{}'
@ -109,6 +112,7 @@ linters:
- goconst
- gocritic
- gocyclo
- godox
- gofmt
- goimports
- gomnd

View file

@ -0,0 +1,3 @@
package main
// This file is used to declare module plugins.

View file

@ -46,3 +46,10 @@
link: /contributing/faq/
- label: This Website
link: /contributing/website/
- label: Plugins
items:
- label: Module Plugin System
link: /contributing/new-linters/
- label: Go Plugin System
link: /contributing/private-linters/

View file

@ -2,10 +2,6 @@
title: Contributing FAQ
---
## How to write a custom linter
See [there](/contributing/new-linters#how-to-write-a-custom-linter).
## How to add a new open-source linter to `golangci-lint`
See [there](/contributing/new-linters#how-to-add-a-public-linter-to-golangci-lint).
@ -16,7 +12,7 @@ See [there](/contributing/new-linters#how-to-add-a-private-linter-to-golangci-li
## How to update existing linter
Just update it's version in `go.mod`.
Just update its version in `go.mod`.
## How to add configuration option to existing linter

View file

@ -46,75 +46,7 @@ After that:
Some people and organizations may choose to have custom-made linters run as a part of `golangci-lint`.
Typically, these linters can't be open-sourced or too specific.
Such linters can be added through Go's plugin library.
Such linters can be added through 2 plugin systems:
For a private linter (which acts as a plugin) to work properly,
the plugin as well as the golangci-lint binary **needs to be built for the same environment**.
`CGO_ENABLED` is another requirement.
This means that `golangci-lint` needs to be built for whatever machine you intend to run it on
(cloning the golangci-lint repository and running a `CGO_ENABLED=1 make build` should do the trick for your machine).
### Configure a Plugin
If you already have a linter plugin available, you can follow these steps to define its usage in a projects `.golangci.yml` file.
An example linter can be found at [here](https://github.com/golangci/example-plugin-linter).
If you're looking for instructions on how to configure your own custom linter, they can be found further down.
1. If the project you want to lint does not have one already, copy the [.golangci.yml](https://github.com/golangci/golangci-lint/blob/master/.golangci.yml) to the root directory.
2. Adjust the yaml to appropriate `linters-settings:custom` entries as so:
```yaml
linters-settings:
custom:
example:
path: /example.so
description: The description of the linter
original-url: github.com/golangci/example-linter
settings: # Settings are optional.
one: Foo
two:
- name: Bar
three:
name: Bar
```
That is all the configuration that is required to run a custom linter in your project.
Custom linters are enabled by default, but abide by the same rules as other linters.
If the disable all option is specified either on command line or in `.golang.yml` files `linters.disable-all: true`, custom linters will be disabled;
they can be re-enabled by adding them to the `linters:enable` list,
or providing the enabled option on the command line, `golangci-lint run -Eexample`.
The configuration inside the `settings` field of linter have some limitations (there are NOT related to the plugin system itself):
we use Viper to handle the configuration but Viper put all the keys in lowercase, and `.` cannot be used inside a key.
### Create a Plugin
Your linter must provide one or more `golang.org/x/tools/go/analysis.Analyzer` structs.
Your project should also use `go.mod`.
All versions of libraries that overlap `golangci-lint` (including replaced libraries) MUST be set to the same version as `golangci-lint`.
You can see the versions by running `go version -m golangci-lint`.
You'll also need to create a Go file like `plugin/example.go`.
This file MUST be in the package `main`, and MUST define an exposed function called `New` with the following signature:
```go
func New(conf any) ([]*analysis.Analyzer, error) {
// ...
}
```
See [plugin/example.go](https://github.com/golangci/example-plugin-linter/blob/master/plugin/example.go) for more info.
To build the plugin, from the root project directory, run:
```bash
go build -buildmode=plugin plugin/example.go
```
This will create a plugin `*.so` file that can be copied into your project or another well known location for usage in `golangci-lint`.
- [Go Plugin System](/plugins/module-plugins)
- [Module Plugin System](/plugins/go-plugins)

View file

@ -0,0 +1,76 @@
---
title: Go Plugin System
---
Private linters can be added through [Go's plugin system](https://pkg.go.dev/plugin).
For a private linter (which acts as a plugin) to work properly,
the plugin as well as the golangci-lint binary **needs to be built for the same environment**.
`CGO_ENABLED` is another requirement.
This means that `golangci-lint` needs to be built for whatever machine you intend to run it on
(cloning the golangci-lint repository and running a `CGO_ENABLED=1 make build` should do the trick for your machine).
## Create a Plugin
Your linter must provide one or more `golang.org/x/tools/go/analysis.Analyzer` structs.
Your project should also use `go.mod`.
All versions of libraries that overlap `golangci-lint` (including replaced libraries) MUST be set to the same version as `golangci-lint`.
You can see the versions by running `go version -m golangci-lint`.
You'll also need to create a Go file like `plugin/example.go`.
This file MUST be in the package `main`, and MUST define an exposed function called `New` with the following signature:
```go
func New(conf any) ([]*analysis.Analyzer, error) {
// ...
}
```
See [plugin/example.go](https://github.com/golangci/example-plugin-linter/blob/master/plugin/example.go) for more info.
To build the plugin, from the root project directory, run:
```bash
go build -buildmode=plugin plugin/example.go
```
This will create a plugin `*.so` file that can be copied into your project or another well known location for usage in `golangci-lint`.
## Configure a Plugin
If you already have a linter plugin available, you can follow these steps to define its usage in a projects `.golangci.yml` file.
An example linter can be found at [here](https://github.com/golangci/example-plugin-linter).
If you're looking for instructions on how to configure your own custom linter, they can be found further down.
1. If the project you want to lint does not have one already, copy the [.golangci.yml](https://github.com/golangci/golangci-lint/blob/master/.golangci.yml) to the root directory.
2. Adjust the yaml to appropriate `linters-settings.custom` entries as so:
```yaml title=.golangci.yml
linters-settings:
custom:
example:
path: /example.so
description: The description of the linter
original-url: github.com/golangci/example-linter
settings: # Settings are optional.
one: Foo
two:
- name: Bar
three:
name: Bar
```
That is all the configuration that is required to run a custom linter in your project.
Custom linters are enabled by default, but abide by the same rules as other linters.
If the disable all option is specified either on command line or in `.golang.yml` files `linters.disable-all: true`, custom linters will be disabled;
they can be re-enabled by adding them to the `linters.enable` list,
or providing the enabled option on the command line, `golangci-lint run -Eexample`.
The configuration inside the `settings` field of linter have some limitations (there are NOT related to the plugin system itself):
we use Viper to handle the configuration but Viper put all the keys in lowercase, and `.` cannot be used inside a key.

View file

@ -0,0 +1,71 @@
---
title: Module Plugin System
---
An example linter can be found at [here](https://github.com/golangci/example-plugin-module-linter/settings).
## The Automatic Way
- define your building configuration into `.custom-gcl.yml`
- run the command `golangci-lint custom` ( or `golangci-lint custom -v` to have logs)
- define the plugin inside the `linters-settings.custom` section with the type `module`.
- run your custom version of golangci-lint
Requirements:
- Go
- git
### Configuration Example
```yaml title=.custom-gcl.yml
version: v1.57.0
plugins:
# a plugin from a Go proxy
- module: 'github.com/golangci/plugin1'
import: 'github.com/golangci/plugin1/foo'
version: v1.0.0
# a plugin from local source
- module: 'github.com/golangci/plugin2'
path: /my/local/path/plugin2
```
```yaml title=.golangci.yml
linters-settings:
custom:
foo:
type: "module"
description: This is an example usage of a plugin linter.
settings:
message: hello
linters:
disable-all: true
enable:
- foo
```
## The Manual Way
- add a blank-import of your module inside `cmd/golangci-lint/plugins.go`
- run `go mod tidy`. (the module containing the plugin will be imported)
- run `make build`
- define the plugin inside the configuration `linters-settings.custom` section with the type `module`.
- run your custom version of golangci-lint
### Configuration Example
```yaml title=.golangci.yml
linters-settings:
custom:
foo:
type: "module"
description: This is an example usage of a plugin linter.
settings:
message: hello
linters:
disable-all: true
enable:
- foo
```

1
go.mod
View file

@ -44,6 +44,7 @@ require (
github.com/golangci/dupl v0.0.0-20180902072040-3e9179ac440a
github.com/golangci/gofmt v0.0.0-20231018234816-f50ced29576e
github.com/golangci/misspell v0.4.1
github.com/golangci/plugin-module-register v0.0.0-20240305222101-f76272ec86ee
github.com/golangci/revgrep v0.5.2
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed
github.com/gordonklaus/ineffassign v0.1.0

2
go.sum generated
View file

@ -231,6 +231,8 @@ github.com/golangci/gofmt v0.0.0-20231018234816-f50ced29576e h1:ULcKCDV1LOZPFxGZ
github.com/golangci/gofmt v0.0.0-20231018234816-f50ced29576e/go.mod h1:Pm5KhLPA8gSnQwrQ6ukebRcapGb/BG9iUkdaiCcGHJM=
github.com/golangci/misspell v0.4.1 h1:+y73iSicVy2PqyX7kmUefHusENlrP9YwuHZHPLGQj/g=
github.com/golangci/misspell v0.4.1/go.mod h1:9mAN1quEo3DlpbaIKKyEvRxK1pwqR9s/Sea1bJCtlNI=
github.com/golangci/plugin-module-register v0.0.0-20240305222101-f76272ec86ee h1:nl5nPZ5b2O2dj5+LizmFQ8gNq0r65OfALkp0M8EWJ8E=
github.com/golangci/plugin-module-register v0.0.0-20240305222101-f76272ec86ee/go.mod h1:mGTAkB/NoZMvAGMkiv4+pmmwVO+Gp+zeV77nggByWCc=
github.com/golangci/revgrep v0.5.2 h1:EndcWoRhcnfj2NHQ+28hyuXpLMF+dQmCN+YaeeIl4FU=
github.com/golangci/revgrep v0.5.2/go.mod h1:bjAMA+Sh/QUfTDcHzxfyHxr4xKvllVr/0sCv2e7jJHA=
github.com/golangci/unconvert v0.0.0-20240309020433-c5143eacb3ed h1:IURFTjxeTfNFP0hTEi1YKjB/ub8zkpaOqFFMApi2EAs=

81
pkg/commands/custom.go Normal file
View file

@ -0,0 +1,81 @@
package commands
import (
"context"
"fmt"
"log"
"os"
"github.com/spf13/cobra"
"github.com/golangci/golangci-lint/pkg/commands/internal"
"github.com/golangci/golangci-lint/pkg/logutils"
)
const envKeepTempFiles = "CUSTOM_GCL_KEEP_TEMP_FILES"
type customCommand struct {
cmd *cobra.Command
cfg *internal.Configuration
log logutils.Log
}
func newCustomCommand(logger logutils.Log) *customCommand {
c := &customCommand{log: logger}
customCmd := &cobra.Command{
Use: "custom",
Short: "Build a version of golangci-lint with custom linters.",
Args: cobra.NoArgs,
PreRunE: c.preRunE,
RunE: c.runE,
}
c.cmd = customCmd
return c
}
func (c *customCommand) preRunE(_ *cobra.Command, _ []string) error {
cfg, err := internal.LoadConfiguration()
if err != nil {
return err
}
err = cfg.Validate()
if err != nil {
return err
}
c.cfg = cfg
return nil
}
func (c *customCommand) runE(_ *cobra.Command, _ []string) error {
ctx := context.Background()
tmp, err := os.MkdirTemp(os.TempDir(), "custom-gcl")
if err != nil {
return fmt.Errorf("create temporary directory: %w", err)
}
defer func() {
if os.Getenv(envKeepTempFiles) != "" {
log.Printf("WARN: The env var %s has been dectected: the temporary directory is preserved: %s", envKeepTempFiles, tmp)
return
}
_ = os.RemoveAll(tmp)
}()
err = internal.NewBuilder(c.log, c.cfg, tmp).Build(ctx)
if err != nil {
return fmt.Errorf("build process: %w", err)
}
return nil
}

View file

@ -53,8 +53,7 @@ func newHelpCommand(logger logutils.Log) *helpCommand {
func (c *helpCommand) preRunE(_ *cobra.Command, _ []string) error {
// The command doesn't depend on the real configuration.
// It just needs the list of all plugins and all presets.
dbManager, err := lintersdb.NewManager(c.log.Child(logutils.DebugKeyLintersDB), config.NewDefault(),
lintersdb.NewPluginBuilder(c.log), lintersdb.NewLinterBuilder())
dbManager, err := lintersdb.NewManager(c.log.Child(logutils.DebugKeyLintersDB), config.NewDefault(), lintersdb.NewLinterBuilder())
if err != nil {
return err
}

View file

@ -0,0 +1,219 @@
package internal
import (
"context"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"unicode"
"github.com/golangci/golangci-lint/pkg/logutils"
)
// Builder runs all the required commands to build a binary.
type Builder struct {
cfg *Configuration
log logutils.Log
root string
repo string
}
// NewBuilder creates a new Builder.
func NewBuilder(logger logutils.Log, cfg *Configuration, root string) *Builder {
return &Builder{
cfg: cfg,
log: logger,
root: root,
repo: filepath.Join(root, "golangci-lint"),
}
}
// Build builds the custom binary.
func (b Builder) Build(ctx context.Context) error {
b.log.Infof("Cloning golangci-lint repository.")
err := b.clone(ctx)
if err != nil {
return fmt.Errorf("clone golangci-lint: %w", err)
}
b.log.Infof("Adding plugin imports.")
err = b.updatePluginsFile()
if err != nil {
return fmt.Errorf("update plugin file: %w", err)
}
b.log.Infof("Adding replace directives.")
err = b.addReplaceDirectives(ctx)
if err != nil {
return fmt.Errorf("add replace directives: %w", err)
}
b.log.Infof("Running go mod tidy.")
err = b.goModTidy(ctx)
if err != nil {
return fmt.Errorf("go mod tidy: %w", err)
}
b.log.Infof("Building golangci-lint binary.")
binaryName := b.getBinaryName()
err = b.goBuild(ctx, binaryName)
if err != nil {
return fmt.Errorf("build golangci-lint binary: %w", err)
}
b.log.Infof("Moving golangci-lint binary.")
err = b.copyBinary(binaryName)
if err != nil {
return fmt.Errorf("move golangci-lint binary: %w", err)
}
return nil
}
func (b Builder) clone(ctx context.Context) error {
//nolint:gosec // the variable is sanitized.
cmd := exec.CommandContext(ctx,
"git", "clone", "--branch", sanitizeVersion(b.cfg.Version),
"--single-branch", "--depth", "1", "-c advice.detachedHead=false", "-q",
"https://github.com/golangci/golangci-lint.git",
)
cmd.Dir = b.root
output, err := cmd.CombinedOutput()
if err != nil {
b.log.Infof(string(output))
return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err)
}
return nil
}
func (b Builder) addReplaceDirectives(ctx context.Context) error {
for _, plugin := range b.cfg.Plugins {
if plugin.Path == "" {
continue
}
replace := fmt.Sprintf("%s=%s", plugin.Module, plugin.Path)
cmd := exec.CommandContext(ctx, "go", "mod", "edit", "-replace", replace)
cmd.Dir = b.repo
b.log.Infof("run: %s", strings.Join(cmd.Args, " "))
output, err := cmd.CombinedOutput()
if err != nil {
b.log.Warnf(string(output))
return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err)
}
}
return nil
}
func (b Builder) goModTidy(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "go", "mod", "tidy")
cmd.Dir = b.repo
output, err := cmd.CombinedOutput()
if err != nil {
b.log.Warnf(string(output))
return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err)
}
return nil
}
func (b Builder) goBuild(ctx context.Context, binaryName string) error {
//nolint:gosec // the variable is sanitized.
cmd := exec.CommandContext(ctx, "go", "build",
"-ldflags",
fmt.Sprintf(
"-s -w -X 'main.version=%s-custom-gcl' -X 'main.date=%s'",
sanitizeVersion(b.cfg.Version), time.Now().UTC().String(),
),
"-o", binaryName,
"./cmd/golangci-lint",
)
cmd.Dir = b.repo
output, err := cmd.CombinedOutput()
if err != nil {
b.log.Warnf(string(output))
return fmt.Errorf("%s: %w", strings.Join(cmd.Args, " "), err)
}
return nil
}
func (b Builder) copyBinary(binaryName string) error {
src := filepath.Join(b.repo, binaryName)
source, err := os.Open(filepath.Clean(src))
if err != nil {
return fmt.Errorf("open source file: %w", err)
}
defer func() { _ = source.Close() }()
info, err := source.Stat()
if err != nil {
return fmt.Errorf("stat source file: %w", err)
}
if b.cfg.Destination != "" {
err = os.MkdirAll(b.cfg.Destination, os.ModePerm)
if err != nil {
return fmt.Errorf("create destination directory: %w", err)
}
}
dst, err := os.OpenFile(filepath.Join(b.cfg.Destination, binaryName), os.O_RDWR|os.O_CREATE|os.O_TRUNC, info.Mode())
if err != nil {
return fmt.Errorf("create destination file: %w", err)
}
defer func() { _ = dst.Close() }()
_, err = io.Copy(dst, source)
if err != nil {
return fmt.Errorf("copy source to destination: %w", err)
}
return nil
}
func (b Builder) getBinaryName() string {
name := b.cfg.Name
if runtime.GOOS == "windows" {
name += ".exe"
}
return name
}
func sanitizeVersion(v string) string {
fn := func(c rune) bool {
return !(unicode.IsLetter(c) || unicode.IsNumber(c) || c == '.' || c == '/')
}
return strings.Join(strings.FieldsFunc(v, fn), "")
}

View file

@ -0,0 +1,57 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_sanitizeVersion(t *testing.T) {
testCases := []struct {
desc string
input string
expected string
}{
{
desc: "ampersand",
input: " te&st",
expected: "test",
},
{
desc: "pipe",
input: " te|st",
expected: "test",
},
{
desc: "version",
input: "v1.2.3",
expected: "v1.2.3",
},
{
desc: "branch",
input: "feat/test",
expected: "feat/test",
},
{
desc: "branch",
input: "value --key",
expected: "valuekey",
},
{
desc: "hash",
input: "cd8b1177",
expected: "cd8b1177",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
v := sanitizeVersion(test.input)
assert.Equal(t, test.expected, v)
})
}
}

View file

@ -0,0 +1,138 @@
package internal
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
const base = ".custom-gcl"
const defaultBinaryName = "custom-gcl"
// Configuration represents the configuration file.
type Configuration struct {
// golangci-lint version.
Version string `yaml:"version"`
// Name of the binary.
Name string `yaml:"name,omitempty"`
// Destination is the path to a directory to store the binary.
Destination string `yaml:"destination,omitempty"`
// Plugins information.
Plugins []*Plugin `yaml:"plugins,omitempty"`
}
// Validate checks and clean the configuration.
func (c *Configuration) Validate() error {
if strings.TrimSpace(c.Version) == "" {
return errors.New("root field 'version' is required")
}
if strings.TrimSpace(c.Name) == "" {
c.Name = defaultBinaryName
}
if len(c.Plugins) == 0 {
return errors.New("no plugins defined")
}
for _, plugin := range c.Plugins {
if strings.TrimSpace(plugin.Module) == "" {
return errors.New("field 'module' is required")
}
if strings.TrimSpace(plugin.Import) == "" {
plugin.Import = plugin.Module
}
if strings.TrimSpace(plugin.Path) == "" && strings.TrimSpace(plugin.Version) == "" {
return errors.New("missing information: 'version' or 'path' should be provided")
}
if strings.TrimSpace(plugin.Path) != "" && strings.TrimSpace(plugin.Version) != "" {
return errors.New("invalid configuration: 'version' and 'path' should not be provided at the same time")
}
if strings.TrimSpace(plugin.Path) == "" {
continue
}
abs, err := filepath.Abs(plugin.Path)
if err != nil {
return err
}
plugin.Path = abs
}
return nil
}
// Plugin represents information about a plugin.
type Plugin struct {
// Module name.
Module string `yaml:"module"`
// Import to use.
Import string `yaml:"import,omitempty"`
// Version of the module.
// Only for module available through a Go proxy.
Version string `yaml:"version,omitempty"`
// Path to the local module.
// Only for local module.
Path string `yaml:"path,omitempty"`
}
func LoadConfiguration() (*Configuration, error) {
configFilePath, err := findConfigurationFile()
if err != nil {
return nil, fmt.Errorf("file %s not found: %w", configFilePath, err)
}
file, err := os.Open(configFilePath)
if err != nil {
return nil, fmt.Errorf("file %s open: %w", configFilePath, err)
}
var cfg Configuration
err = yaml.NewDecoder(file).Decode(&cfg)
if err != nil {
return nil, fmt.Errorf("YAML decoding: %w", err)
}
return &cfg, nil
}
func findConfigurationFile() (string, error) {
entries, err := os.ReadDir(".")
if err != nil {
return "", fmt.Errorf("read directory: %w", err)
}
for _, entry := range entries {
ext := filepath.Ext(entry.Name())
switch strings.ToLower(strings.TrimPrefix(ext, ".")) {
case "yml", "yaml", "json":
if isConf(ext, entry.Name()) {
return entry.Name(), nil
}
}
}
return "", errors.New("configuration file not found")
}
func isConf(ext, name string) bool {
return base+ext == name
}

View file

@ -0,0 +1,127 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConfiguration_Validate(t *testing.T) {
testCases := []struct {
desc string
cfg *Configuration
}{
{
desc: "version",
cfg: &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "example.org/foo/bar",
Import: "example.org/foo/bar/test",
Version: "v1.2.3",
},
},
},
},
{
desc: "path",
cfg: &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "example.org/foo/bar",
Import: "example.org/foo/bar/test",
Path: "/my/path",
},
},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.cfg.Validate()
require.NoError(t, err)
})
}
}
func TestConfiguration_Validate_error(t *testing.T) {
testCases := []struct {
desc string
cfg *Configuration
expected string
}{
{
desc: "missing version",
cfg: &Configuration{},
expected: "root field 'version' is required",
},
{
desc: "no plugins",
cfg: &Configuration{
Version: "v1.57.0",
},
expected: "no plugins defined",
},
{
desc: "missing module",
cfg: &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "",
Import: "example.org/foo/bar/test",
Version: "v1.2.3",
Path: "/my/path",
},
},
},
expected: "field 'module' is required",
},
{
desc: "module version and path",
cfg: &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "example.org/foo/bar",
Import: "example.org/foo/bar/test",
Version: "v1.2.3",
Path: "/my/path",
},
},
},
expected: "invalid configuration: 'version' and 'path' should not be provided at the same time",
},
{
desc: "no module version and path",
cfg: &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "example.org/foo/bar",
Import: "example.org/foo/bar/test",
},
},
},
expected: "missing information: 'version' or 'path' should be provided",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.cfg.Validate()
assert.EqualError(t, err, test.expected)
})
}
}

View file

@ -0,0 +1,69 @@
package internal
import (
"bytes"
"fmt"
"go/format"
"os"
"path/filepath"
"text/template"
)
const importsTemplate = `
package main
import (
{{range .Imports -}}
_ "{{.}}"
{{end -}}
)
`
func (b Builder) updatePluginsFile() error {
importsDest := filepath.Join(b.repo, "cmd", "golangci-lint", "plugins.go")
info, err := os.Stat(importsDest)
if err != nil {
return fmt.Errorf("file %s not found: %w", importsDest, err)
}
source, err := generateImports(b.cfg)
if err != nil {
return fmt.Errorf("generate imports: %w", err)
}
b.log.Infof("generated imports info %s:\n%s\n", importsDest, source)
err = os.WriteFile(filepath.Clean(importsDest), source, info.Mode())
if err != nil {
return fmt.Errorf("write file %s: %w", importsDest, err)
}
return nil
}
func generateImports(cfg *Configuration) ([]byte, error) {
impTmpl, err := template.New("plugins.go").Parse(importsTemplate)
if err != nil {
return nil, fmt.Errorf("parse template: %w", err)
}
var imps []string
for _, plugin := range cfg.Plugins {
imps = append(imps, plugin.Import)
}
buf := &bytes.Buffer{}
err = impTmpl.Execute(buf, map[string]any{"Imports": imps})
if err != nil {
return nil, fmt.Errorf("execute template: %w", err)
}
source, err := format.Source(buf.Bytes())
if err != nil {
return nil, fmt.Errorf("format source: %w", err)
}
return source, nil
}

View file

@ -0,0 +1,36 @@
package internal
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_generateImports(t *testing.T) {
cfg := &Configuration{
Version: "v1.57.0",
Plugins: []*Plugin{
{
Module: "example.org/foo/bar",
Import: "example.org/foo/bar/test",
Version: "v1.2.3",
},
{
Module: "example.com/foo/bar",
Import: "example.com/foo/bar/test",
Path: "/my/path",
},
},
}
data, err := generateImports(cfg)
require.NoError(t, err)
expected, err := os.ReadFile(filepath.Join("testdata", "imports.go"))
require.NoError(t, err)
assert.Equal(t, expected, data)
}

View file

@ -0,0 +1,6 @@
package main
import (
_ "example.com/foo/bar/test"
_ "example.org/foo/bar/test"
)

View file

@ -65,7 +65,7 @@ func (c *lintersCommand) preRunE(cmd *cobra.Command, _ []string) error {
}
dbManager, err := lintersdb.NewManager(c.log.Child(logutils.DebugKeyLintersDB), c.cfg,
lintersdb.NewPluginBuilder(c.log), lintersdb.NewLinterBuilder())
lintersdb.NewLinterBuilder(), lintersdb.NewPluginModuleBuilder(c.log), lintersdb.NewPluginGoBuilder(c.log))
if err != nil {
return err
}

View file

@ -63,6 +63,7 @@ func newRootCommand(info BuildInfo) *rootCommand {
newCacheCommand().cmd,
newConfigCommand(log).cmd,
newVersionCommand(info).cmd,
newCustomCommand(log).cmd,
)
rootCmd.SetHelpCommand(newHelpCommand(log).cmd)

View file

@ -180,7 +180,7 @@ func (c *runCommand) persistentPostRunE(_ *cobra.Command, _ []string) error {
func (c *runCommand) preRunE(_ *cobra.Command, _ []string) error {
dbManager, err := lintersdb.NewManager(c.log.Child(logutils.DebugKeyLintersDB), c.cfg,
lintersdb.NewPluginBuilder(c.log), lintersdb.NewLinterBuilder())
lintersdb.NewLinterBuilder(), lintersdb.NewPluginModuleBuilder(c.log), lintersdb.NewPluginGoBuilder(c.log))
if err != nil {
return err
}

View file

@ -3,6 +3,7 @@ package config
import (
"encoding"
"errors"
"fmt"
"runtime"
"gopkg.in/yaml.v3"
@ -281,7 +282,17 @@ type LintersSettings struct {
}
func (s *LintersSettings) Validate() error {
return s.Govet.Validate()
if err := s.Govet.Validate(); err != nil {
return err
}
for name, settings := range s.Custom {
if err := settings.Validate(); err != nil {
return fmt.Errorf("custom linter %q: %w", name, err)
}
}
return nil
}
type AsasalintSettings struct {
@ -946,17 +957,15 @@ type WSLSettings struct {
}
// CustomLinterSettings encapsulates the meta-data of a private linter.
// For example, a private linter may be added to the golangci config file as shown below.
//
// linters-settings:
// custom:
// example:
// path: /example.so
// description: The description of the linter
// original-url: github.com/golangci/example-linter
type CustomLinterSettings struct {
// Type plugin type.
// It can be `goplugin` or `module`.
Type string `mapstructure:"type"`
// Path to a plugin *.so file that implements the private linter.
// Only for Go plugin system.
Path string
// Description describes the purpose of the private linter.
Description string
// OriginalURL The URL containing the source code for the private linter.
@ -965,3 +974,19 @@ type CustomLinterSettings struct {
// Settings plugin settings only work with linterdb.PluginConstructor symbol.
Settings any
}
func (s *CustomLinterSettings) Validate() error {
if s.Type == "module" {
if s.Path != "" {
return errors.New("path not supported with module type")
}
return nil
}
if s.Path == "" {
return errors.New("path is required")
}
return nil
}

View file

@ -0,0 +1,236 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestLintersSettings_Validate(t *testing.T) {
testCases := []struct {
desc string
settings *LintersSettings
}{
{
desc: "custom linter",
settings: &LintersSettings{
Custom: map[string]CustomLinterSettings{
"example": {
Type: "module",
},
},
},
},
{
desc: "govet",
settings: &LintersSettings{
Govet: GovetSettings{
Enable: []string{"a"},
DisableAll: true,
},
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.NoError(t, err)
})
}
}
func TestLintersSettings_Validate_error(t *testing.T) {
testCases := []struct {
desc string
settings *LintersSettings
expected string
}{
{
desc: "custom linter error",
settings: &LintersSettings{
Custom: map[string]CustomLinterSettings{
"example": {
Type: "module",
Path: "example",
},
},
},
expected: `custom linter "example": path not supported with module type`,
},
{
desc: "govet error",
settings: &LintersSettings{
Govet: GovetSettings{
EnableAll: true,
DisableAll: true,
},
},
expected: "govet: enable-all and disable-all can't be combined",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.EqualError(t, err, test.expected)
})
}
}
func TestCustomLinterSettings_Validate(t *testing.T) {
testCases := []struct {
desc string
settings *CustomLinterSettings
}{
{
desc: "only path",
settings: &CustomLinterSettings{
Path: "example",
},
},
{
desc: "path and type goplugin",
settings: &CustomLinterSettings{
Type: "goplugin",
Path: "example",
},
},
{
desc: "type module",
settings: &CustomLinterSettings{
Type: "module",
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.NoError(t, err)
})
}
}
func TestCustomLinterSettings_Validate_error(t *testing.T) {
testCases := []struct {
desc string
settings *CustomLinterSettings
expected string
}{
{
desc: "missing path",
settings: &CustomLinterSettings{},
expected: "path is required",
},
{
desc: "module and path",
settings: &CustomLinterSettings{
Type: "module",
Path: "example",
},
expected: "path not supported with module type",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.EqualError(t, err, test.expected)
})
}
}
func TestGovetSettings_Validate(t *testing.T) {
testCases := []struct {
desc string
settings *GovetSettings
}{
{
desc: "empty",
settings: &GovetSettings{},
},
{
desc: "disable-all and enable",
settings: &GovetSettings{
Enable: []string{"a"},
DisableAll: true,
},
},
{
desc: "enable-all and disable",
settings: &GovetSettings{
Disable: []string{"a"},
EnableAll: true,
},
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.NoError(t, err)
})
}
}
func TestGovetSettings_Validate_error(t *testing.T) {
testCases := []struct {
desc string
settings *GovetSettings
expected string
}{
{
desc: "enable-all and disable-all",
settings: &GovetSettings{
EnableAll: true,
DisableAll: true,
},
expected: "govet: enable-all and disable-all can't be combined",
},
{
desc: "enable-all and enable",
settings: &GovetSettings{
EnableAll: true,
Enable: []string{"a"},
},
expected: "govet: enable-all and enable can't be combined",
},
{
desc: "disable-all and disable",
settings: &GovetSettings{
DisableAll: true,
Disable: []string{"a"},
},
expected: "govet: disable-all and disable can't be combined",
},
}
for _, test := range testCases {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
err := test.settings.Validate()
assert.EqualError(t, err, test.expected)
})
}
}

View file

@ -16,9 +16,9 @@ func NewLinterBuilder() *LinterBuilder {
// Build loads all the "internal" linters.
// The configuration is use for the linter settings.
func (b LinterBuilder) Build(cfg *config.Config) []*linter.Config {
func (b LinterBuilder) Build(cfg *config.Config) ([]*linter.Config, error) {
if cfg == nil {
return nil
return nil, nil
}
const megacheckName = "megacheck"
@ -707,5 +707,5 @@ func (b LinterBuilder) Build(cfg *config.Config) []*linter.Config {
WithSince("v1.26.0").
WithPresets(linter.PresetStyle).
WithURL("https://github.com/golangci/golangci-lint/blob/master/pkg/golinters/nolintlint/README.md"),
}
}, nil
}

View file

@ -14,43 +14,51 @@ import (
"github.com/golangci/golangci-lint/pkg/logutils"
)
const goPluginType = "goplugin"
type AnalyzerPlugin interface {
GetAnalyzers() []*analysis.Analyzer
}
// PluginBuilder builds the custom linters (plugins) based on the configuration.
type PluginBuilder struct {
// PluginGoBuilder builds the custom linters (Go plugin) based on the configuration.
type PluginGoBuilder struct {
log logutils.Log
}
// NewPluginBuilder creates new PluginBuilder.
func NewPluginBuilder(log logutils.Log) *PluginBuilder {
return &PluginBuilder{log: log}
// NewPluginGoBuilder creates new PluginGoBuilder.
func NewPluginGoBuilder(log logutils.Log) *PluginGoBuilder {
return &PluginGoBuilder{log: log}
}
// Build loads custom linters that are specified in the golangci-lint config file.
func (b *PluginBuilder) Build(cfg *config.Config) []*linter.Config {
func (b *PluginGoBuilder) Build(cfg *config.Config) ([]*linter.Config, error) {
if cfg == nil || b.log == nil {
return nil
return nil, nil
}
var linters []*linter.Config
for name, settings := range cfg.LintersSettings.Custom {
lc, err := b.loadConfig(cfg, name, settings)
if settings.Type != goPluginType && settings.Type != "" {
continue
}
settings := settings
lc, err := b.loadConfig(cfg, name, &settings)
if err != nil {
b.log.Errorf("Unable to load custom analyzer %s:%s, %v", name, settings.Path, err)
return nil, fmt.Errorf("unable to load custom analyzer %q: %s, %w", name, settings.Path, err)
} else {
linters = append(linters, lc)
}
}
return linters
return linters, nil
}
// loadConfig loads the configuration of private linters.
// Private linters are dynamically loaded from .so plugin files.
func (b *PluginBuilder) loadConfig(cfg *config.Config, name string, settings config.CustomLinterSettings) (*linter.Config, error) {
func (b *PluginGoBuilder) loadConfig(cfg *config.Config, name string, settings *config.CustomLinterSettings) (*linter.Config, error) {
analyzers, err := b.getAnalyzerPlugin(cfg, settings.Path, settings.Settings)
if err != nil {
return nil, err
@ -74,7 +82,7 @@ func (b *PluginBuilder) loadConfig(cfg *config.Config, name string, settings con
// and returns the 'AnalyzerPlugin' interface implemented by the private plugin.
// An error is returned if the private linter cannot be loaded
// or the linter does not implement the AnalyzerPlugin interface.
func (b *PluginBuilder) getAnalyzerPlugin(cfg *config.Config, path string, settings any) ([]*analysis.Analyzer, error) {
func (b *PluginGoBuilder) getAnalyzerPlugin(cfg *config.Config, path string, settings any) ([]*analysis.Analyzer, error) {
if !filepath.IsAbs(path) {
// resolve non-absolute paths relative to config file's directory
path = filepath.Join(cfg.GetConfigDir(), path)
@ -93,7 +101,7 @@ func (b *PluginBuilder) getAnalyzerPlugin(cfg *config.Config, path string, setti
return analyzers, nil
}
func (b *PluginBuilder) lookupPlugin(plug *plugin.Plugin, settings any) ([]*analysis.Analyzer, error) {
func (b *PluginGoBuilder) lookupPlugin(plug *plugin.Plugin, settings any) ([]*analysis.Analyzer, error) {
symbol, err := plug.Lookup("New")
if err != nil {
analyzers, errP := b.lookupAnalyzerPlugin(plug)
@ -113,7 +121,7 @@ func (b *PluginBuilder) lookupPlugin(plug *plugin.Plugin, settings any) ([]*anal
return constructor(settings)
}
func (b *PluginBuilder) lookupAnalyzerPlugin(plug *plugin.Plugin) ([]*analysis.Analyzer, error) {
func (b *PluginGoBuilder) lookupAnalyzerPlugin(plug *plugin.Plugin) ([]*analysis.Analyzer, error) {
symbol, err := plug.Lookup("AnalyzerPlugin")
if err != nil {
return nil, err

View file

@ -0,0 +1,85 @@
package lintersdb
import (
"fmt"
"strings"
"github.com/golangci/plugin-module-register/register"
"github.com/golangci/golangci-lint/pkg/config"
"github.com/golangci/golangci-lint/pkg/golinters/goanalysis"
"github.com/golangci/golangci-lint/pkg/lint/linter"
"github.com/golangci/golangci-lint/pkg/logutils"
)
const modulePluginType = "module"
// PluginModuleBuilder builds the custom linters (module plugin) based on the configuration.
type PluginModuleBuilder struct {
log logutils.Log
}
// NewPluginModuleBuilder creates new PluginModuleBuilder.
func NewPluginModuleBuilder(log logutils.Log) *PluginModuleBuilder {
return &PluginModuleBuilder{log: log}
}
// Build loads custom linters that are specified in the golangci-lint config file.
func (b *PluginModuleBuilder) Build(cfg *config.Config) ([]*linter.Config, error) {
if cfg == nil || b.log == nil {
return nil, nil
}
var linters []*linter.Config
for name, settings := range cfg.LintersSettings.Custom {
if settings.Type != modulePluginType {
continue
}
b.log.Infof("Loaded %s: %s", settings.Path, name)
newPlugin, err := register.GetPlugin(name)
if err != nil {
return nil, fmt.Errorf("plugin(%s): %w", name, err)
}
p, err := newPlugin(settings.Settings)
if err != nil {
return nil, fmt.Errorf("plugin(%s): newPlugin %w", name, err)
}
analyzers, err := p.BuildAnalyzers()
if err != nil {
return nil, fmt.Errorf("plugin(%s): BuildAnalyzers %w", name, err)
}
customLinter := goanalysis.NewLinter(name, settings.Description, analyzers, nil)
switch strings.ToLower(p.GetLoadMode()) {
case register.LoadModeSyntax:
customLinter = customLinter.WithLoadMode(goanalysis.LoadModeSyntax)
case register.LoadModeTypesInfo:
customLinter = customLinter.WithLoadMode(goanalysis.LoadModeTypesInfo)
default:
customLinter = customLinter.WithLoadMode(goanalysis.LoadModeTypesInfo)
}
lc := linter.NewConfig(customLinter).
WithEnabledByDefault().
WithURL(settings.OriginalURL)
switch strings.ToLower(p.GetLoadMode()) {
case register.LoadModeSyntax:
// noop
case register.LoadModeTypesInfo:
lc = lc.WithLoadForGoAnalysis()
default:
lc = lc.WithLoadForGoAnalysis()
}
linters = append(linters, lc)
}
return linters, nil
}

View file

@ -1,6 +1,7 @@
package lintersdb
import (
"fmt"
"os"
"slices"
"sort"
@ -17,7 +18,7 @@ import (
const EnvTestRun = "GL_TEST_RUN"
type Builder interface {
Build(cfg *config.Config) []*linter.Config
Build(cfg *config.Config) ([]*linter.Config, error)
}
// Manager is a type of database for all linters (internals or plugins).
@ -48,7 +49,12 @@ func NewManager(log logutils.Log, cfg *config.Config, builders ...Builder) (*Man
}
for _, builder := range builders {
m.linters = append(m.linters, builder.Build(m.cfg)...)
linters, err := builder.Build(m.cfg)
if err != nil {
return nil, fmt.Errorf("build linters: %w", err)
}
m.linters = append(m.linters, linters...)
}
for _, lc := range m.linters {

View file

@ -34,8 +34,7 @@ func newNolint2FileIssue(line int) result.Issue {
}
func newTestNolintProcessor(log logutils.Log) *Nolint {
dbManager, _ := lintersdb.NewManager(log, config.NewDefault(),
lintersdb.NewPluginBuilder(log), lintersdb.NewLinterBuilder())
dbManager, _ := lintersdb.NewManager(log, config.NewDefault(), lintersdb.NewLinterBuilder())
return NewNolint(log, dbManager, nil)
}